mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
desktop: workflow builder UX — graph, titles, properties pod
- Human-friendly workflow titles (display + prompt scaffold) and header cleanup - Definition graph with responsive node sizing and properties/run history panel - Prompt-based draft flow, form labels, E2E updates; dependency bumps as needed Made-with: Cursor
This commit is contained in:
@@ -41,6 +41,7 @@
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
|
||||
339
desktop/pnpm-lock.yaml
generated
339
desktop/pnpm-lock.yaml
generated
@@ -59,6 +59,9 @@ importers:
|
||||
'@tauri-apps/plugin-updater':
|
||||
specifier: ^2.10.0
|
||||
version: 2.10.0
|
||||
'@xyflow/react':
|
||||
specifier: ^12.10.2
|
||||
version: 12.10.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -237,49 +240,49 @@ packages:
|
||||
hasBin: true
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.4.6':
|
||||
resolution: {integrity: sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==}
|
||||
resolution: {integrity: sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==, tarball: https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.6.tgz}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.4.6':
|
||||
resolution: {integrity: sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==}
|
||||
resolution: {integrity: sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==, tarball: https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.6.tgz}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.6':
|
||||
resolution: {integrity: sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==}
|
||||
resolution: {integrity: sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.6.tgz}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.4.6':
|
||||
resolution: {integrity: sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==}
|
||||
resolution: {integrity: sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.6.tgz}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.4.6':
|
||||
resolution: {integrity: sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==}
|
||||
resolution: {integrity: sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.6.tgz}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.4.6':
|
||||
resolution: {integrity: sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==}
|
||||
resolution: {integrity: sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.6.tgz}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.4.6':
|
||||
resolution: {integrity: sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==}
|
||||
resolution: {integrity: sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==, tarball: https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.6.tgz}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@biomejs/cli-win32-x64@2.4.6':
|
||||
resolution: {integrity: sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==}
|
||||
resolution: {integrity: sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==, tarball: https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.6.tgz}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -294,157 +297,157 @@ packages:
|
||||
react: ^16.8 || ^17 || ^18
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.3':
|
||||
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
|
||||
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
|
||||
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.27.3':
|
||||
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
|
||||
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.27.3':
|
||||
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
|
||||
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
|
||||
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.27.3':
|
||||
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
|
||||
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
|
||||
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.3':
|
||||
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
|
||||
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
|
||||
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.27.3':
|
||||
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
|
||||
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.27.3':
|
||||
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
|
||||
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.27.3':
|
||||
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
|
||||
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.3':
|
||||
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
|
||||
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.3':
|
||||
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
|
||||
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.3':
|
||||
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
|
||||
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.27.3':
|
||||
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
|
||||
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.27.3':
|
||||
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
|
||||
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
|
||||
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.3':
|
||||
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
|
||||
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
|
||||
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.3':
|
||||
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
|
||||
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
|
||||
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.27.3':
|
||||
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
|
||||
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
|
||||
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.27.3':
|
||||
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
|
||||
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.27.3':
|
||||
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
|
||||
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -914,138 +917,127 @@ packages:
|
||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
|
||||
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
|
||||
resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
|
||||
resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.59.0':
|
||||
resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
|
||||
resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
|
||||
resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
|
||||
resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
|
||||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==, tarball: https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
|
||||
resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==, tarball: https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.59.0':
|
||||
resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
|
||||
resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.59.0':
|
||||
resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
|
||||
resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
|
||||
resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||
resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
|
||||
resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -1101,67 +1093,67 @@ packages:
|
||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
||||
resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==}
|
||||
resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==, tarball: https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/cli-darwin-x64@2.10.1':
|
||||
resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==}
|
||||
resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==, tarball: https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
|
||||
resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==}
|
||||
resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==, tarball: https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.10.1':
|
||||
resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==}
|
||||
resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==, tarball: https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
|
||||
resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==}
|
||||
resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==, tarball: https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
|
||||
resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==}
|
||||
resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==, tarball: https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
|
||||
resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==}
|
||||
resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==, tarball: https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.10.1':
|
||||
resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==}
|
||||
resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==, tarball: https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
|
||||
resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==}
|
||||
resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==, tarball: https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.10.1':
|
||||
resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==}
|
||||
resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==, tarball: https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.10.1':
|
||||
resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==}
|
||||
resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==, tarball: https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -1195,6 +1187,24 @@ packages:
|
||||
'@types/babel__traverse@7.28.0':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==, tarball: https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz}
|
||||
|
||||
'@types/d3-drag@3.0.7':
|
||||
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==, tarball: https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==, tarball: https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz}
|
||||
|
||||
'@types/d3-selection@3.0.11':
|
||||
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==, tarball: https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz}
|
||||
|
||||
'@types/d3-transition@3.0.9':
|
||||
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==, tarball: https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz}
|
||||
|
||||
'@types/d3-zoom@3.0.8':
|
||||
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==, tarball: https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz}
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||
|
||||
@@ -1236,6 +1246,15 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@xyflow/react@12.10.2':
|
||||
resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==, tarball: https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
|
||||
'@xyflow/system@0.0.76':
|
||||
resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==, tarball: https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz}
|
||||
|
||||
any-promise@1.3.0:
|
||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||
|
||||
@@ -1307,6 +1326,9 @@ packages:
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
classcat@5.0.5:
|
||||
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==, tarball: https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz}
|
||||
|
||||
classnames@2.5.1:
|
||||
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||
|
||||
@@ -1332,6 +1354,44 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==, tarball: https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-dispatch@3.0.1:
|
||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==, tarball: https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==, tarball: https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==, tarball: https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==, tarball: https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-selection@3.0.0:
|
||||
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==, tarball: https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==, tarball: https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-transition@3.0.1:
|
||||
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==, tarball: https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
d3-selection: 2 - 3
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==, tarball: https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -1412,12 +1472,12 @@ packages:
|
||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
@@ -2047,6 +2107,11 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==, tarball: https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
@@ -2107,6 +2172,21 @@ packages:
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
zustand@4.5.7:
|
||||
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==, tarball: https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz}
|
||||
engines: {node: '>=12.7.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16.8'
|
||||
immer: '>=9.0.6'
|
||||
react: '>=16.8'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
|
||||
zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
@@ -3015,6 +3095,27 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-drag@3.0.7':
|
||||
dependencies:
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.3
|
||||
|
||||
'@types/d3-selection@3.0.11': {}
|
||||
|
||||
'@types/d3-transition@3.0.9':
|
||||
dependencies:
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/d3-zoom@3.0.8':
|
||||
dependencies:
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
@@ -3061,6 +3162,29 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@xyflow/react@12.10.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@xyflow/system': 0.0.76
|
||||
classcat: 5.0.5
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
zustand: 4.5.7(@types/react@19.2.14)(react@19.2.4)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
|
||||
'@xyflow/system@0.0.76':
|
||||
dependencies:
|
||||
'@types/d3-drag': 3.0.7
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-selection': 3.0.11
|
||||
'@types/d3-transition': 3.0.9
|
||||
'@types/d3-zoom': 3.0.8
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
|
||||
any-promise@1.3.0: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
@@ -3131,6 +3255,8 @@ snapshots:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
classcat@5.0.5: {}
|
||||
|
||||
classnames@2.5.1: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
@@ -3145,6 +3271,42 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-dispatch@3.0.1: {}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-selection@3.0.0: {}
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
d3-dispatch: 3.0.1
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -4153,6 +4315,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
use-sync-external-store@1.6.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vfile-message@4.0.3:
|
||||
@@ -4186,4 +4352,11 @@ snapshots:
|
||||
|
||||
yaml@2.8.3: {}
|
||||
|
||||
zustand@4.5.7(@types/react@19.2.14)(react@19.2.4):
|
||||
dependencies:
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
react: 19.2.4
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -19,12 +20,14 @@ import {
|
||||
import {
|
||||
getWorkflowDescription,
|
||||
getWorkflowDisplayStatus,
|
||||
getWorkflowDisplayTitle,
|
||||
getWorkflowTriggerSummary,
|
||||
} from "./workflowDefinition";
|
||||
|
||||
type WorkflowCardProps = {
|
||||
workflow: Workflow;
|
||||
channelName?: string;
|
||||
selected?: boolean;
|
||||
onSelect: (workflowId: string) => void;
|
||||
onTrigger: (workflowId: string) => void;
|
||||
onEdit: (workflow: Workflow) => void;
|
||||
@@ -51,6 +54,7 @@ function StatusBadge({ status }: { status: Workflow["status"] }) {
|
||||
export function WorkflowCard({
|
||||
workflow,
|
||||
channelName,
|
||||
selected = false,
|
||||
onSelect,
|
||||
onTrigger,
|
||||
onEdit,
|
||||
@@ -58,12 +62,16 @@ export function WorkflowCard({
|
||||
onDelete,
|
||||
}: WorkflowCardProps) {
|
||||
const displayStatus = getWorkflowDisplayStatus(workflow);
|
||||
const displayTitle = getWorkflowDisplayTitle(workflow);
|
||||
const description = getWorkflowDescription(workflow.definition);
|
||||
const triggerSummary = getWorkflowTriggerSummary(workflow.definition);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full rounded-lg border bg-card p-3 text-left transition-colors hover:bg-muted/50"
|
||||
className={cn(
|
||||
"relative w-full rounded-lg border bg-card p-3 text-left transition-colors hover:bg-muted/50",
|
||||
selected && "border-primary/45 ring-2 ring-primary/15",
|
||||
)}
|
||||
data-testid={`workflow-card-${workflow.id}`}
|
||||
>
|
||||
<button
|
||||
@@ -71,16 +79,14 @@ export function WorkflowCard({
|
||||
onClick={() => onSelect(workflow.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="sr-only">View {workflow.name}</span>
|
||||
<span className="sr-only">View {displayTitle}</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{workflow.name}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium">{displayTitle}</span>
|
||||
<StatusBadge status={displayStatus} />
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-3 pl-6 text-[11px] text-muted-foreground">
|
||||
|
||||
306
desktop/src/features/workflows/ui/WorkflowDefinitionGraph.tsx
Normal file
306
desktop/src/features/workflows/ui/WorkflowDefinitionGraph.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
MarkerType,
|
||||
Position,
|
||||
ReactFlow,
|
||||
useReactFlow,
|
||||
type Edge,
|
||||
type Node,
|
||||
} from "@xyflow/react";
|
||||
import * as React from "react";
|
||||
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
asRecord,
|
||||
asString,
|
||||
getStepDetails,
|
||||
getStepTitle,
|
||||
getTriggerDetails,
|
||||
getTriggerTitle,
|
||||
} from "./workflowDefinitionNodeInfo";
|
||||
|
||||
type WorkflowDefinitionGraphProps = {
|
||||
definition: Record<string, unknown>;
|
||||
selectedNodeId: string | null;
|
||||
onSelectedNodeIdChange: (id: string | null) => void;
|
||||
/** Merged onto the outer wrapper (e.g. full-bleed: no radius, no frame border). */
|
||||
className?: string;
|
||||
/** Use bottom-left when a right overlay covers the default controls corner. */
|
||||
controlsPosition?: "bottom-left" | "bottom-right";
|
||||
};
|
||||
|
||||
/**
|
||||
* Positions are proportional to this reference width so spacing stays tight when
|
||||
* `nodeWidth` changes (including canvas-percent sizing).
|
||||
*/
|
||||
const LAYOUT_REF_NODE_W = 200;
|
||||
|
||||
function GraphNodeLabel({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
details,
|
||||
nodeWidth,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
details: string[];
|
||||
nodeWidth: number;
|
||||
}) {
|
||||
/** Scales with canvas-derived node width (cap keeps type readable). */
|
||||
const rootPx = Math.max(6, Math.min(8, 5.1 + nodeWidth * 0.018));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-0 w-full max-w-full px-[0.55em] py-[0.58em] text-left"
|
||||
style={{ fontSize: `${rootPx}px`, lineHeight: 1.32 }}
|
||||
>
|
||||
<p className="text-[0.86em] font-semibold uppercase tracking-[0.13em] text-muted-foreground">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<p className="mt-[0.32em] text-[1.05em] font-semibold leading-tight text-foreground">
|
||||
{title}
|
||||
</p>
|
||||
{subtitle ? (
|
||||
<p className="mt-[0.32em] font-mono text-[0.9em] leading-tight text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
{details.length > 0 ? (
|
||||
<div className="mt-[0.5em] space-y-[0.18em]">
|
||||
{details.map((detail) => (
|
||||
<p
|
||||
className="text-[0.94em] leading-snug text-muted-foreground"
|
||||
key={detail}
|
||||
>
|
||||
{detail}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeNodeWidth(canvasW: number, canvasH: number): number {
|
||||
const w = Math.max(canvasW, 1);
|
||||
const h = Math.max(canvasH, 1);
|
||||
const basis = Math.min(w, h);
|
||||
/** ~20% of the smaller canvas dimension, bounded so nodes stay compact but legible */
|
||||
return Math.round(Math.min(142, Math.max(76, basis * 0.2)));
|
||||
}
|
||||
|
||||
function buildGraph(
|
||||
definition: Record<string, unknown>,
|
||||
selectedNodeId: string | null,
|
||||
nodeWidth: number,
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const trigger = asRecord(definition.trigger);
|
||||
const rawSteps = Array.isArray(definition.steps) ? definition.steps : [];
|
||||
const steps = rawSteps.map((step) => asRecord(step)).filter(Boolean) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
|
||||
const r = nodeWidth / LAYOUT_REF_NODE_W;
|
||||
const nodeX = 26 * r;
|
||||
const triggerY = 14 * r;
|
||||
const stepY0 = 118 * r;
|
||||
const stepDeltaY = 108 * r;
|
||||
|
||||
const selectedBorder = "2px solid hsl(var(--primary) / 0.85)";
|
||||
const defaultBorderTrigger = "1px solid hsl(var(--primary) / 0.35)";
|
||||
const defaultBorderStep = "1px solid hsl(var(--border))";
|
||||
|
||||
const borderRadius = Math.max(6, Math.round(11 * r));
|
||||
const boxShadow = `0 ${Math.max(2, Math.round(3 * r))}px ${Math.max(8, Math.round(12 * r))}px hsl(var(--foreground) / 0.05)`;
|
||||
const markerSize = Math.max(7, Math.round(10 * r));
|
||||
|
||||
const nodes: Node[] = [
|
||||
{
|
||||
id: "trigger",
|
||||
position: { x: nodeX, y: triggerY },
|
||||
sourcePosition: Position.Bottom,
|
||||
data: {
|
||||
label: (
|
||||
<GraphNodeLabel
|
||||
details={getTriggerDetails(trigger)}
|
||||
eyebrow="Trigger"
|
||||
nodeWidth={nodeWidth}
|
||||
title={getTriggerTitle(trigger)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
width: nodeWidth,
|
||||
borderRadius,
|
||||
border:
|
||||
selectedNodeId === "trigger" ? selectedBorder : defaultBorderTrigger,
|
||||
background: "hsl(var(--card))",
|
||||
color: "hsl(var(--card-foreground))",
|
||||
boxShadow,
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const edges: Edge[] = [];
|
||||
let previousNodeId = "trigger";
|
||||
|
||||
steps.forEach((step, index) => {
|
||||
const stepId = asString(step.id) ?? `step_${index + 1}`;
|
||||
const nodeId = `step-${stepId}`;
|
||||
const isSelected = selectedNodeId === nodeId;
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
position: { x: nodeX, y: stepY0 + index * stepDeltaY },
|
||||
sourcePosition: Position.Bottom,
|
||||
targetPosition: Position.Top,
|
||||
data: {
|
||||
label: (
|
||||
<GraphNodeLabel
|
||||
details={getStepDetails(step)}
|
||||
eyebrow={`Step ${index + 1}`}
|
||||
nodeWidth={nodeWidth}
|
||||
subtitle={stepId}
|
||||
title={getStepTitle(step, index)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
width: nodeWidth,
|
||||
borderRadius,
|
||||
border: isSelected ? selectedBorder : defaultBorderStep,
|
||||
background: "hsl(var(--card))",
|
||||
color: "hsl(var(--card-foreground))",
|
||||
boxShadow,
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
},
|
||||
});
|
||||
|
||||
edges.push({
|
||||
id: `edge-${previousNodeId}-${nodeId}`,
|
||||
source: previousNodeId,
|
||||
target: nodeId,
|
||||
type: "smoothstep",
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: markerSize,
|
||||
height: markerSize,
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
style: {
|
||||
stroke: "hsl(var(--muted-foreground))",
|
||||
strokeWidth: Math.max(1, Math.min(1.35, 0.75 + nodeWidth / 220)),
|
||||
},
|
||||
});
|
||||
|
||||
previousNodeId = nodeId;
|
||||
});
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
function RefitWhenLayoutChanges({ layoutKey }: { layoutKey: string }) {
|
||||
const { fitView } = useReactFlow();
|
||||
React.useEffect(() => {
|
||||
void layoutKey;
|
||||
const id = requestAnimationFrame(() => {
|
||||
fitView({ duration: 0, padding: 0.18 });
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [fitView, layoutKey]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function WorkflowDefinitionGraph({
|
||||
definition,
|
||||
selectedNodeId,
|
||||
onSelectedNodeIdChange,
|
||||
className,
|
||||
controlsPosition = "bottom-right",
|
||||
}: WorkflowDefinitionGraphProps) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [canvasSize, setCanvasSize] = React.useState({ w: 400, h: 280 });
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect;
|
||||
setCanvasSize({ w: width, h: height });
|
||||
});
|
||||
ro.observe(el);
|
||||
setCanvasSize({ w: el.clientWidth, h: el.clientHeight });
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const nodeWidth = React.useMemo(
|
||||
() => computeNodeWidth(canvasSize.w, canvasSize.h),
|
||||
[canvasSize.h, canvasSize.w],
|
||||
);
|
||||
|
||||
const stepCount = React.useMemo(() => {
|
||||
const raw = definition.steps;
|
||||
return Array.isArray(raw) ? raw.length : 0;
|
||||
}, [definition.steps]);
|
||||
|
||||
const definitionDigest = React.useMemo(
|
||||
() => JSON.stringify(definition),
|
||||
[definition],
|
||||
);
|
||||
|
||||
const refitLayoutKey = `${nodeWidth}-${stepCount}-${definitionDigest}`;
|
||||
|
||||
const { nodes, edges } = React.useMemo(
|
||||
() => buildGraph(definition, selectedNodeId, nodeWidth),
|
||||
[definition, selectedNodeId, nodeWidth],
|
||||
);
|
||||
|
||||
const onNodeClick = React.useCallback(
|
||||
(_event: React.MouseEvent, node: Node) => {
|
||||
onSelectedNodeIdChange(node.id);
|
||||
},
|
||||
[onSelectedNodeIdChange],
|
||||
);
|
||||
|
||||
const onPaneClick = React.useCallback(() => {
|
||||
onSelectedNodeIdChange(null);
|
||||
}, [onSelectedNodeIdChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full min-h-[12rem] w-full overflow-hidden rounded-xl border border-border/70 bg-muted/15",
|
||||
className,
|
||||
)}
|
||||
ref={containerRef}
|
||||
>
|
||||
<ReactFlow
|
||||
edges={edges}
|
||||
elementsSelectable={false}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.18 }}
|
||||
nodes={nodes}
|
||||
nodesConnectable={false}
|
||||
nodesDraggable={false}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
panOnDrag
|
||||
proOptions={{ hideAttribution: true }}
|
||||
selectNodesOnDrag={false}
|
||||
>
|
||||
<RefitWhenLayoutChanges layoutKey={refitLayoutKey} />
|
||||
<Background gap={20} size={1} variant={BackgroundVariant.Dots} />
|
||||
<Controls position={controlsPosition} showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
import { getWorkflowDisplayTitle } from "@/features/workflows/ui/workflowDefinition";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -31,7 +32,7 @@ export function WorkflowDeleteDialog({
|
||||
<AlertDialogTitle>Delete workflow?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{workflow
|
||||
? `Delete "${workflow.name}". This will stop all future triggers and remove the workflow permanently.`
|
||||
? `Delete "${getWorkflowDisplayTitle(workflow)}". This will stop all future triggers and remove the workflow permanently.`
|
||||
: "Delete this workflow."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, ChevronRight, Pencil, Play, X } from "lucide-react";
|
||||
import { ArrowLeft, LayoutList, Pencil, Play } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
@@ -7,23 +7,36 @@ import {
|
||||
useWorkflowQuery,
|
||||
useWorkflowRunsQuery,
|
||||
} from "@/features/workflows/hooks";
|
||||
import { WorkflowRunTrace } from "@/features/workflows/ui/WorkflowRunTrace";
|
||||
import { WorkflowDefinitionGraph } from "@/features/workflows/ui/WorkflowDefinitionGraph";
|
||||
import { WorkflowPropertiesPanel } from "@/features/workflows/ui/WorkflowPropertiesPanel";
|
||||
import { WorkflowRunHistoryList } from "@/features/workflows/ui/WorkflowRunHistoryList";
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
getWorkflowDescription,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/shared/ui/sheet";
|
||||
import {
|
||||
getWorkflowDisplayStatus,
|
||||
getWorkflowTriggerSummary,
|
||||
getWorkflowDisplayTitle,
|
||||
} from "./workflowDefinition";
|
||||
|
||||
type WorkflowDetailPanelProps = {
|
||||
workflowId: string;
|
||||
channelName?: string | null;
|
||||
onClose: () => void;
|
||||
onEdit: (workflow: Workflow) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Diagram uses the full content area; on large screens the properties panel is a
|
||||
* floating rounded column over the map (no split layout / no vertical chrome).
|
||||
*/
|
||||
export function WorkflowDetailPanel({
|
||||
workflowId,
|
||||
channelName,
|
||||
onClose,
|
||||
onEdit,
|
||||
}: WorkflowDetailPanelProps) {
|
||||
@@ -31,18 +44,35 @@ export function WorkflowDetailPanel({
|
||||
const runsQuery = useWorkflowRunsQuery(workflowId);
|
||||
const triggerMutation = useTriggerWorkflowMutation(workflowId);
|
||||
const [selectedRunId, setSelectedRunId] = React.useState<string | null>(null);
|
||||
const [graphSelection, setGraphSelection] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [propertiesOpen, setPropertiesOpen] = React.useState(false);
|
||||
|
||||
const handleGraphSelection = React.useCallback((id: string | null) => {
|
||||
setGraphSelection(id);
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(max-width: 1023px)").matches
|
||||
) {
|
||||
setPropertiesOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const workflow = workflowQuery.data;
|
||||
const runs = runsQuery.data ?? [];
|
||||
const approvalsQuery = useRunApprovalsQuery(workflowId, selectedRunId);
|
||||
const workflowDescription = workflow
|
||||
? getWorkflowDescription(workflow.definition)
|
||||
: null;
|
||||
const triggerSummary = workflow
|
||||
? getWorkflowTriggerSummary(workflow.definition)
|
||||
: null;
|
||||
const workflowStatus = workflow ? getWorkflowDisplayStatus(workflow) : null;
|
||||
|
||||
const approvalsSnapshot = React.useMemo(
|
||||
() => ({
|
||||
isFetching: approvalsQuery.isFetching,
|
||||
error: approvalsQuery.error,
|
||||
data: approvalsQuery.data,
|
||||
}),
|
||||
[approvalsQuery.isFetching, approvalsQuery.error, approvalsQuery.data],
|
||||
);
|
||||
|
||||
async function handleTrigger() {
|
||||
try {
|
||||
const response = await triggerMutation.mutateAsync();
|
||||
@@ -52,31 +82,66 @@ export function WorkflowDetailPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const propertiesTitle =
|
||||
graphSelection === null
|
||||
? "Workflow"
|
||||
: graphSelection === "trigger"
|
||||
? "Trigger"
|
||||
: "Step";
|
||||
|
||||
const runHistoryProps = {
|
||||
approvalsQuery: approvalsSnapshot,
|
||||
onToggleRun: (id: string | null) => setSelectedRunId(id),
|
||||
runs,
|
||||
selectedRunId,
|
||||
workflow,
|
||||
workflowQueryError: workflowQuery.isError,
|
||||
workflowQueryLoading: workflowQuery.isLoading,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col bg-background"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background"
|
||||
data-testid="workflow-detail-panel"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold">
|
||||
{workflow?.name ?? "Loading..."}
|
||||
</h3>
|
||||
{workflowStatus ? <RunStatusBadge status={workflowStatus} /> : null}
|
||||
<header
|
||||
className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2 sm:px-4"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
|
||||
<Button
|
||||
aria-label="Back to workflow list"
|
||||
className="shrink-0 gap-1.5 px-2 sm:px-3"
|
||||
onClick={onClose}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 shrink-0" />
|
||||
<span className="hidden sm:inline">Back</span>
|
||||
</Button>
|
||||
<div className="min-w-0 pl-0 sm:pl-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold">
|
||||
{workflow ? getWorkflowDisplayTitle(workflow) : "Loading..."}
|
||||
</h3>
|
||||
{workflowStatus ? (
|
||||
<RunStatusBadge status={workflowStatus} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{workflowDescription ? (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{workflowDescription}
|
||||
</p>
|
||||
) : null}
|
||||
{triggerSummary ? (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{triggerSummary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{workflow ? (
|
||||
<Button
|
||||
className="lg:hidden"
|
||||
onClick={() => setPropertiesOpen(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<LayoutList className="mr-1 h-3 w-3" />
|
||||
Properties
|
||||
</Button>
|
||||
) : null}
|
||||
{workflow ? (
|
||||
<Button
|
||||
onClick={() => onEdit(workflow)}
|
||||
@@ -96,163 +161,92 @@ export function WorkflowDetailPanel({
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
{triggerMutation.isPending ? "Triggering..." : "Trigger"}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Close detail panel"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{triggerMutation.isError ? (
|
||||
<div className="border-b px-4 py-2 text-xs text-red-400">
|
||||
<div className="border-b border-border px-4 py-2 text-xs text-red-400">
|
||||
Failed to trigger workflow
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{workflow ? (
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Definition
|
||||
</h4>
|
||||
<pre className="max-h-64 overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs leading-relaxed">
|
||||
{JSON.stringify(workflow.definition, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Map: edge-to-edge on lg so the canvas reads as full-bleed behind the overlay */}
|
||||
<div className="relative z-0 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col p-2 sm:p-3 lg:absolute lg:inset-0 lg:p-0">
|
||||
{workflow ? (
|
||||
<WorkflowDefinitionGraph
|
||||
className="min-h-0 flex-1 lg:rounded-none lg:border-0 lg:bg-muted/25"
|
||||
controlsPosition="bottom-left"
|
||||
definition={workflow.definition}
|
||||
onSelectedNodeIdChange={handleGraphSelection}
|
||||
selectedNodeId={graphSelection}
|
||||
/>
|
||||
) : workflowQuery.isError ? (
|
||||
<div className="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-xl border border-border/70">
|
||||
<p className="text-sm text-red-400">Failed to load workflow</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[12rem] flex-1 items-center justify-center rounded-xl border border-border/70">
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Run History
|
||||
</h4>
|
||||
{runs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No runs yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{runs.map((run) => {
|
||||
const isSelected = selectedRunId === run.id;
|
||||
const duration = formatRunDuration(
|
||||
run.startedAt,
|
||||
run.completedAt,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden rounded-xl border bg-card/70 transition-colors ${
|
||||
isSelected
|
||||
? "border-primary/40 bg-primary/5 shadow-sm"
|
||||
: "border-border/70 hover:bg-muted/20"
|
||||
}`}
|
||||
key={run.id}
|
||||
>
|
||||
<button
|
||||
aria-expanded={isSelected}
|
||||
className="w-full px-4 py-3 text-left"
|
||||
data-testid={
|
||||
isSelected ? "workflow-selected-run" : undefined
|
||||
}
|
||||
onClick={() =>
|
||||
setSelectedRunId(isSelected ? null : run.id)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate font-mono text-xs font-medium">
|
||||
{run.id.slice(0, 8)}
|
||||
</span>
|
||||
<RunStatusBadge status={run.status} />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{new Date(
|
||||
run.createdAt * 1000,
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
{run.executionTrace.length}{" "}
|
||||
{run.executionTrace.length === 1
|
||||
? "step"
|
||||
: "steps"}
|
||||
</span>
|
||||
{duration ? <span>{duration}</span> : null}
|
||||
{run.currentStep !== null ? (
|
||||
<span>
|
||||
Current step {run.currentStep + 1}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{run.errorMessage ? (
|
||||
<p className="mt-2 break-words pl-6 text-xs text-destructive">
|
||||
{run.errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isSelected ? (
|
||||
<div className="border-t border-border/60 bg-background/60 px-4 py-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<span>Execution Trace</span>
|
||||
{approvalsQuery.isFetching ? (
|
||||
<span className="text-[10px] tracking-[0.12em] text-muted-foreground/80">
|
||||
Refreshing approvals...
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{approvalsQuery.error instanceof Error ? (
|
||||
<p className="mb-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{approvalsQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
<WorkflowRunTrace
|
||||
approvals={approvalsQuery.data}
|
||||
run={run}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{workflow ? (
|
||||
<aside
|
||||
aria-label="Flow diagram properties"
|
||||
className="pointer-events-none absolute inset-y-2 right-2 z-10 hidden w-[min(20rem,calc(100vw-8rem))] max-w-sm flex-col lg:flex"
|
||||
>
|
||||
<div className="pointer-events-auto flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-white/10 bg-background/45 shadow-2xl backdrop-blur-xl backdrop-saturate-150 dark:border-white/5 dark:bg-background/35 supports-[backdrop-filter]:bg-background/40">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4">
|
||||
<WorkflowPropertiesPanel
|
||||
channelName={channelName}
|
||||
className="gap-4 p-0"
|
||||
selectedNodeId={graphSelection}
|
||||
workflow={workflow}
|
||||
/>
|
||||
<div className="mt-5 border-t border-border/40 pt-5">
|
||||
<WorkflowRunHistoryList {...runHistoryProps} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[min(40vh,20rem)] shrink-0 overflow-hidden border-t border-border/80 bg-background/95 lg:hidden">
|
||||
<div className="max-h-[min(40vh,20rem)] overflow-y-auto p-3 sm:p-4">
|
||||
<WorkflowRunHistoryList {...runHistoryProps} />
|
||||
</div>
|
||||
) : workflowQuery.isError ? (
|
||||
<div className="flex h-32 flex-col items-center justify-center gap-2">
|
||||
<p className="text-sm text-red-400">Failed to load workflow</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{workflow ? (
|
||||
<div className="lg:hidden">
|
||||
<Sheet onOpenChange={setPropertiesOpen} open={propertiesOpen}>
|
||||
<SheetContent
|
||||
className="flex max-h-[90vh] flex-col overflow-hidden p-0"
|
||||
side="bottom"
|
||||
>
|
||||
<SheetHeader className="border-b px-4 py-3 text-left">
|
||||
<SheetTitle>{propertiesTitle}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<WorkflowPropertiesPanel
|
||||
channelName={channelName}
|
||||
selectedNodeId={graphSelection}
|
||||
workflow={workflow}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRunDuration(
|
||||
startedAt: number | null,
|
||||
completedAt: number | null,
|
||||
) {
|
||||
if (startedAt === null || completedAt === null) return null;
|
||||
const seconds = completedAt - startedAt;
|
||||
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`;
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: string) {
|
||||
return status.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
} from "@/shared/ui/dialog";
|
||||
import { ChannelCombobox } from "./ChannelCombobox";
|
||||
import { WorkflowFormBuilder } from "./WorkflowFormBuilder";
|
||||
import { WorkflowPromptBuilder } from "./WorkflowPromptBuilder";
|
||||
import { getWorkflowYamlValidationError } from "./workflowFormTypes";
|
||||
import { WorkflowWebhookSecretDialog } from "./WorkflowWebhookSecretDialog";
|
||||
import { draftWorkflowYamlFromPrompt } from "./workflowPromptScaffold";
|
||||
import { FieldLabel } from "./workflowFormPrimitives";
|
||||
|
||||
type DialogMode = "create" | "edit" | "duplicate";
|
||||
@@ -67,6 +70,7 @@ export function WorkflowDialog({
|
||||
open,
|
||||
workflow,
|
||||
}: WorkflowDialogProps) {
|
||||
const isCreateMode = mode === "create";
|
||||
const channelId =
|
||||
mode === "edit" && workflow?.channelId
|
||||
? workflow.channelId
|
||||
@@ -76,6 +80,10 @@ export function WorkflowDialog({
|
||||
const [yamlDefinition, setYamlDefinition] = React.useState(() =>
|
||||
getInitialYaml(mode, workflow),
|
||||
);
|
||||
const [workflowPrompt, setWorkflowPrompt] = React.useState("");
|
||||
const [createStage, setCreateStage] = React.useState<"prompt" | "setup">(
|
||||
isCreateMode ? "prompt" : "setup",
|
||||
);
|
||||
const [savedWebhookInfo, setSavedWebhookInfo] = React.useState<{
|
||||
relayHttpUrl: string;
|
||||
webhookSecret: string;
|
||||
@@ -88,6 +96,10 @@ export function WorkflowDialog({
|
||||
|
||||
const selectedChannel =
|
||||
channels.find((c) => c.id === selectedChannelId) ?? null;
|
||||
const isPromptStage = isCreateMode && createStage === "prompt";
|
||||
const validationError = isPromptStage
|
||||
? null
|
||||
: getWorkflowYamlValidationError(yamlDefinition);
|
||||
|
||||
const defaultChannelId = channels[0]?.id ?? "";
|
||||
const workflowChannelId = workflow?.channelId ?? null;
|
||||
@@ -103,6 +115,8 @@ export function WorkflowDialog({
|
||||
: defaultChannelId;
|
||||
setSelectedChannelId(newChannelId);
|
||||
setYamlDefinition(getInitialYaml(mode, workflow));
|
||||
setWorkflowPrompt("");
|
||||
setCreateStage(mode === "create" ? "prompt" : "setup");
|
||||
setSavedWebhookInfo(null);
|
||||
resetCreate();
|
||||
resetUpdate();
|
||||
@@ -128,6 +142,19 @@ export function WorkflowDialog({
|
||||
[onOpenChange, resetCreate, resetUpdate],
|
||||
);
|
||||
|
||||
const handleDraftWorkflow = React.useCallback(() => {
|
||||
const trimmedPrompt = workflowPrompt.trim();
|
||||
if (!trimmedPrompt) return;
|
||||
|
||||
mutation.reset();
|
||||
setYamlDefinition(
|
||||
draftWorkflowYamlFromPrompt(trimmedPrompt, {
|
||||
defaultChannelId: selectedChannelId || undefined,
|
||||
}),
|
||||
);
|
||||
setCreateStage("setup");
|
||||
}, [mutation, selectedChannelId, workflowPrompt]);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selectedChannelId || !yamlDefinition.trim()) return;
|
||||
|
||||
@@ -147,8 +174,10 @@ export function WorkflowDialog({
|
||||
}
|
||||
}
|
||||
|
||||
const showChannelSelector = mode !== "edit" && channels.length > 1;
|
||||
const showChannelInfo = mode !== "edit" && channels.length === 1;
|
||||
const showChannelSelector =
|
||||
!isPromptStage && mode !== "edit" && channels.length > 1;
|
||||
const showChannelInfo =
|
||||
!isPromptStage && mode !== "edit" && channels.length === 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -157,81 +186,132 @@ export function WorkflowDialog({
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{TITLES[mode]}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === "edit"
|
||||
? "Modify the workflow definition."
|
||||
: channels.length === 1
|
||||
? "Create a workflow scoped to this channel."
|
||||
: "Define a workflow and assign it to a channel."}
|
||||
{isPromptStage
|
||||
? "Describe the workflow you want and we'll draft the closest setup we can."
|
||||
: mode === "edit"
|
||||
? "Modify the workflow definition."
|
||||
: channels.length === 1
|
||||
? "Review the draft and adjust the workflow before saving."
|
||||
: "Define a workflow and assign it to a channel."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
|
||||
{showChannelSelector ? (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-channel-select">Channel</FieldLabel>
|
||||
<ChannelCombobox
|
||||
channels={channels}
|
||||
{isPromptStage ? (
|
||||
<WorkflowPromptBuilder
|
||||
disabled={mutation.isPending}
|
||||
onChange={setWorkflowPrompt}
|
||||
prompt={workflowPrompt}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{showChannelSelector ? (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-channel-select">Channel</FieldLabel>
|
||||
<ChannelCombobox
|
||||
channels={channels}
|
||||
disabled={mutation.isPending}
|
||||
id="wf-channel-select"
|
||||
onChange={(value) => {
|
||||
mutation.reset();
|
||||
setSelectedChannelId(value);
|
||||
}}
|
||||
value={selectedChannelId}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedChannel
|
||||
? `New workflows will belong to ${selectedChannel.name}.`
|
||||
: "Join or create a channel before adding a workflow."}
|
||||
</p>
|
||||
</div>
|
||||
) : (showChannelInfo || mode === "edit") && selectedChannel ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{mode === "edit"
|
||||
? "Editing workflow in"
|
||||
: "This workflow will be created in"}{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{selectedChannel.name}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isCreateMode && workflowPrompt.trim() ? (
|
||||
<div className="rounded-lg border border-border/70 bg-muted/20 px-3 py-2">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
Drafted from your prompt
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{workflowPrompt.trim()}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<WorkflowFormBuilder
|
||||
disabled={mutation.isPending}
|
||||
id="wf-channel-select"
|
||||
onChange={(value) => {
|
||||
onChange={(yaml) => {
|
||||
mutation.reset();
|
||||
setSelectedChannelId(value);
|
||||
setYamlDefinition(yaml);
|
||||
}}
|
||||
value={selectedChannelId}
|
||||
yaml={yamlDefinition}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedChannel
|
||||
? `New workflows will belong to ${selectedChannel.name}.`
|
||||
: "Join or create a channel before adding a workflow."}
|
||||
</p>
|
||||
</div>
|
||||
) : (showChannelInfo || mode === "edit") && selectedChannel ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{mode === "edit"
|
||||
? "Editing workflow in"
|
||||
: "This workflow will be created in"}{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{selectedChannel.name}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<WorkflowFormBuilder
|
||||
disabled={mutation.isPending}
|
||||
onChange={(yaml) => {
|
||||
mutation.reset();
|
||||
setYamlDefinition(yaml);
|
||||
}}
|
||||
yaml={yamlDefinition}
|
||||
/>
|
||||
|
||||
{mutation.error instanceof Error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{mutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{mutation.error instanceof Error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{mutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 justify-end gap-2 border-t border-border pt-4">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!selectedChannelId ||
|
||||
!yamlDefinition.trim() ||
|
||||
mutation.isPending
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
type="button"
|
||||
>
|
||||
{mutation.isPending ? PENDING_LABELS[mode] : SUBMIT_LABELS[mode]}
|
||||
</Button>
|
||||
<div className="flex flex-shrink-0 items-center justify-between gap-2 border-t border-border pt-4">
|
||||
<div>
|
||||
{isCreateMode && !isPromptStage ? (
|
||||
<Button
|
||||
disabled={mutation.isPending}
|
||||
onClick={() => setCreateStage("prompt")}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Back to prompt
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{isPromptStage ? (
|
||||
<Button
|
||||
disabled={!workflowPrompt.trim() || mutation.isPending}
|
||||
onClick={handleDraftWorkflow}
|
||||
type="button"
|
||||
>
|
||||
Draft workflow
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={
|
||||
!selectedChannelId ||
|
||||
!yamlDefinition.trim() ||
|
||||
validationError !== null ||
|
||||
mutation.isPending
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
type="button"
|
||||
>
|
||||
{mutation.isPending
|
||||
? PENDING_LABELS[mode]
|
||||
: SUBMIT_LABELS[mode]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
TRIGGER_LABELS,
|
||||
TRIGGER_TYPES,
|
||||
formStateToYaml,
|
||||
getWorkflowFormValidationError,
|
||||
getWorkflowYamlValidationError,
|
||||
nextStepId,
|
||||
yamlToFormState,
|
||||
} from "./workflowFormTypes";
|
||||
@@ -208,6 +210,14 @@ export function WorkflowFormBuilder({
|
||||
[formState, updateFormState],
|
||||
);
|
||||
|
||||
const validationError = React.useMemo(
|
||||
() =>
|
||||
mode === "yaml"
|
||||
? getWorkflowYamlValidationError(yaml)
|
||||
: getWorkflowFormValidationError(formState),
|
||||
[formState, mode, yaml],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
@@ -230,6 +240,12 @@ export function WorkflowFormBuilder({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{validationError ? (
|
||||
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{validationError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{mode === "yaml" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Textarea
|
||||
@@ -246,7 +262,7 @@ export function WorkflowFormBuilder({
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-name">Workflow name</FieldLabel>
|
||||
<FieldLabel htmlFor="wf-name">Workflow title</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
@@ -255,7 +271,7 @@ export function WorkflowFormBuilder({
|
||||
onChange={(event) =>
|
||||
updateFormState({ ...formState, name: event.target.value })
|
||||
}
|
||||
placeholder="e.g. deploy_notifier"
|
||||
placeholder="e.g. Deploy notifications"
|
||||
value={formState.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
41
desktop/src/features/workflows/ui/WorkflowPromptBuilder.tsx
Normal file
41
desktop/src/features/workflows/ui/WorkflowPromptBuilder.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { FieldLabel } from "./workflowFormPrimitives";
|
||||
|
||||
type WorkflowPromptBuilderProps = {
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export function WorkflowPromptBuilder({
|
||||
disabled,
|
||||
onChange,
|
||||
prompt,
|
||||
}: WorkflowPromptBuilderProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor="wf-prompt">Describe the workflow</FieldLabel>
|
||||
<Textarea
|
||||
autoCapitalize="off"
|
||||
className="min-h-[180px] resize-y text-sm"
|
||||
disabled={disabled}
|
||||
id="wf-prompt"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder='e.g. When someone posts "deploy", wait 5 minutes and send a message to the channel.'
|
||||
value={prompt}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
We'll draft the closest workflow we can, then you can adjust the
|
||||
trigger, steps, or YAML before saving.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/70 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
Include the trigger, any filters, and what should happen next. Mention
|
||||
timings, reactions, webhook URLs, or approval requirements if they
|
||||
matter.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
desktop/src/features/workflows/ui/WorkflowPropertiesPanel.tsx
Normal file
203
desktop/src/features/workflows/ui/WorkflowPropertiesPanel.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
getWorkflowDescription,
|
||||
getWorkflowDisplayStatus,
|
||||
getWorkflowDisplayTitle,
|
||||
getWorkflowEnabled,
|
||||
getWorkflowTriggerSummary,
|
||||
} from "./workflowDefinition";
|
||||
import {
|
||||
getStepDetails,
|
||||
getStepTitle,
|
||||
getTriggerDetails,
|
||||
getTriggerTitle,
|
||||
resolveGraphNodeSelection,
|
||||
} from "./workflowDefinitionNodeInfo";
|
||||
|
||||
type WorkflowPropertiesPanelProps = {
|
||||
workflow: Workflow;
|
||||
channelName?: string | null;
|
||||
selectedNodeId: string | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function PropertyBlock({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<div className="text-sm text-foreground">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: string) {
|
||||
return status.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
active: "bg-green-500/15 text-green-500",
|
||||
disabled: "bg-muted text-muted-foreground",
|
||||
archived: "bg-amber-500/15 text-amber-500",
|
||||
completed: "bg-green-500/15 text-green-500",
|
||||
failed: "bg-red-500/15 text-red-500",
|
||||
running: "bg-blue-500/15 text-blue-500",
|
||||
pending: "bg-muted text-muted-foreground",
|
||||
cancelled: "bg-muted text-muted-foreground",
|
||||
waiting_approval: "bg-amber-500/15 text-amber-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] ${colors[status] ?? colors.pending}`}
|
||||
>
|
||||
{formatStatusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowPropertiesPanel({
|
||||
workflow,
|
||||
channelName,
|
||||
selectedNodeId,
|
||||
className,
|
||||
}: WorkflowPropertiesPanelProps) {
|
||||
const def = workflow.definition;
|
||||
const resolved = resolveGraphNodeSelection(def, selectedNodeId);
|
||||
|
||||
if (resolved.kind === "trigger") {
|
||||
const title = getTriggerTitle(resolved.trigger);
|
||||
const lines = getTriggerDetails(resolved.trigger);
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-4 p-4", className)}
|
||||
data-testid="workflow-properties-panel"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Trigger</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Read-only summary. Use Edit to change the workflow.
|
||||
</p>
|
||||
</div>
|
||||
<PropertyBlock label="Type">{title}</PropertyBlock>
|
||||
{lines.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Details
|
||||
</p>
|
||||
<ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
|
||||
{lines.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resolved.kind === "step") {
|
||||
const { step, index, stepId } = resolved;
|
||||
const title = getStepTitle(step, index);
|
||||
const lines = getStepDetails(step);
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-4 p-4", className)}
|
||||
data-testid="workflow-properties-panel"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">
|
||||
Step {index + 1}: {title}
|
||||
</h3>
|
||||
<p className="mt-1 font-mono text-xs text-muted-foreground">
|
||||
{stepId}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Read-only summary. Use Edit to change the workflow.
|
||||
</p>
|
||||
</div>
|
||||
{lines.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Details
|
||||
</p>
|
||||
<ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
|
||||
{lines.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No extra fields for this step.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const description = getWorkflowDescription(def);
|
||||
const triggerSummary = getWorkflowTriggerSummary(def);
|
||||
const workflowStatus = getWorkflowDisplayStatus(workflow);
|
||||
const enabled = getWorkflowEnabled(def);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-4 p-4", className)}
|
||||
data-testid="workflow-properties-panel"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Workflow</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Description and trigger summary are below. Select a node on the
|
||||
diagram to focus the trigger or a step. Use Edit to make changes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PropertyBlock label="Title">
|
||||
{getWorkflowDisplayTitle(workflow)}
|
||||
</PropertyBlock>
|
||||
|
||||
{description ? (
|
||||
<PropertyBlock label="Description">{description}</PropertyBlock>
|
||||
) : null}
|
||||
|
||||
{channelName ? (
|
||||
<PropertyBlock label="Channel">{channelName}</PropertyBlock>
|
||||
) : null}
|
||||
|
||||
<PropertyBlock label="Status">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<StatusBadge status={workflowStatus} />
|
||||
<span className="text-muted-foreground">
|
||||
{enabled ? "Enabled in definition" : "Disabled in definition"}
|
||||
</span>
|
||||
</span>
|
||||
</PropertyBlock>
|
||||
|
||||
{triggerSummary ? (
|
||||
<PropertyBlock label="Trigger">{triggerSummary}</PropertyBlock>
|
||||
) : null}
|
||||
|
||||
<details className="rounded-lg border border-border/60 bg-muted/20">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
Raw definition (JSON)
|
||||
</summary>
|
||||
<pre className="max-h-48 overflow-auto border-t border-border/60 p-3 font-mono text-[11px] leading-relaxed">
|
||||
{JSON.stringify(def, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
desktop/src/features/workflows/ui/WorkflowRunHistoryList.tsx
Normal file
175
desktop/src/features/workflows/ui/WorkflowRunHistoryList.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
import { WorkflowRunTrace } from "@/features/workflows/ui/WorkflowRunTrace";
|
||||
import type {
|
||||
Workflow,
|
||||
WorkflowApproval,
|
||||
WorkflowRun,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
type RunApprovalsQuerySnapshot = {
|
||||
isFetching: boolean;
|
||||
error: unknown;
|
||||
data: WorkflowApproval[] | undefined;
|
||||
};
|
||||
|
||||
function formatRunDuration(
|
||||
startedAt: number | null,
|
||||
completedAt: number | null,
|
||||
) {
|
||||
if (startedAt === null || completedAt === null) return null;
|
||||
const seconds = completedAt - startedAt;
|
||||
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`;
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: string) {
|
||||
return status.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function RunStatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
active: "bg-green-500/15 text-green-500",
|
||||
disabled: "bg-muted text-muted-foreground",
|
||||
archived: "bg-amber-500/15 text-amber-500",
|
||||
completed: "bg-green-500/15 text-green-500",
|
||||
failed: "bg-red-500/15 text-red-500",
|
||||
running: "bg-blue-500/15 text-blue-500",
|
||||
pending: "bg-muted text-muted-foreground",
|
||||
cancelled: "bg-muted text-muted-foreground",
|
||||
waiting_approval: "bg-amber-500/15 text-amber-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] ${colors[status] ?? colors.pending}`}
|
||||
>
|
||||
{formatStatusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type WorkflowRunHistoryListProps = {
|
||||
workflow: Workflow | undefined;
|
||||
workflowQueryLoading: boolean;
|
||||
workflowQueryError: boolean;
|
||||
runs: WorkflowRun[];
|
||||
selectedRunId: string | null;
|
||||
onToggleRun: (runId: string | null) => void;
|
||||
approvalsQuery: RunApprovalsQuerySnapshot;
|
||||
/** When set, constrains vertical height (mobile strip under the map) */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function WorkflowRunHistoryList({
|
||||
workflow,
|
||||
workflowQueryLoading,
|
||||
workflowQueryError,
|
||||
runs,
|
||||
selectedRunId,
|
||||
onToggleRun,
|
||||
approvalsQuery,
|
||||
className,
|
||||
}: WorkflowRunHistoryListProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<h4 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Run history
|
||||
</h4>
|
||||
{workflow ? (
|
||||
runs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No runs yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{runs.map((run) => {
|
||||
const isSelected = selectedRunId === run.id;
|
||||
const duration = formatRunDuration(
|
||||
run.startedAt,
|
||||
run.completedAt,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden rounded-xl border bg-card/70 transition-colors ${
|
||||
isSelected
|
||||
? "border-primary/40 bg-primary/5 shadow-sm"
|
||||
: "border-border/70 hover:bg-muted/20"
|
||||
}`}
|
||||
key={run.id}
|
||||
>
|
||||
<button
|
||||
aria-expanded={isSelected}
|
||||
className="w-full px-4 py-3 text-left"
|
||||
data-testid={
|
||||
isSelected ? "workflow-selected-run" : undefined
|
||||
}
|
||||
onClick={() => onToggleRun(isSelected ? null : run.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate font-mono text-xs font-medium">
|
||||
{run.id.slice(0, 8)}
|
||||
</span>
|
||||
<RunStatusBadge status={run.status} />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{new Date(run.createdAt * 1000).toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
{run.executionTrace.length}{" "}
|
||||
{run.executionTrace.length === 1 ? "step" : "steps"}
|
||||
</span>
|
||||
{duration ? <span>{duration}</span> : null}
|
||||
{run.currentStep !== null ? (
|
||||
<span>Current step {run.currentStep + 1}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{run.errorMessage ? (
|
||||
<p className="mt-2 break-words pl-6 text-xs text-destructive">
|
||||
{run.errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isSelected ? (
|
||||
<div className="border-t border-border/60 bg-background/60 px-4 py-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<span>Execution Trace</span>
|
||||
{approvalsQuery.isFetching ? (
|
||||
<span className="text-[10px] tracking-[0.12em] text-muted-foreground/80">
|
||||
Refreshing approvals...
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{approvalsQuery.error instanceof Error ? (
|
||||
<p className="mb-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{approvalsQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
<WorkflowRunTrace
|
||||
approvals={approvalsQuery.data}
|
||||
run={run}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : workflowQueryError ? null : workflowQueryLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
|
||||
@@ -13,22 +12,18 @@ type WorkflowsScreenProps = {
|
||||
channels: Channel[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Full height below the app sidebar — no duplicate page header (the list column
|
||||
* shows “Workflows”). Matches a 3-column wireframe: list | diagram | properties.
|
||||
*/
|
||||
export function WorkflowsScreen({ channels }: WorkflowsScreenProps) {
|
||||
return (
|
||||
<>
|
||||
<ChatHeader
|
||||
description="Create, manage, and monitor automated workflows across your channels."
|
||||
mode="workflows"
|
||||
title="Workflows"
|
||||
/>
|
||||
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading workflows..." />}
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<WorkflowsView channels={channels} />
|
||||
</div>
|
||||
</React.Suspense>
|
||||
</>
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading workflows..." />}
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-2">
|
||||
<WorkflowsView channels={channels} />
|
||||
</div>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getChannelWorkflows,
|
||||
triggerWorkflow,
|
||||
} from "@/shared/api/tauriWorkflows";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
type WorkflowsViewProps = {
|
||||
@@ -65,6 +66,11 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
|
||||
|
||||
const allWorkflows = allWorkflowsQuery.data ?? [];
|
||||
|
||||
const selectedChannelName = selectedWorkflowId
|
||||
? allWorkflows.find((w) => w.workflow.id === selectedWorkflowId)
|
||||
?.channelName
|
||||
: undefined;
|
||||
|
||||
const triggerMutation = useMutation({
|
||||
mutationFn: (workflowId: string) => triggerWorkflow(workflowId),
|
||||
onSuccess: () => {
|
||||
@@ -126,11 +132,22 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 overflow-hidden"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden lg:flex-row"
|
||||
data-testid="workflows-view"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
{/* Workflow list only on overview — hidden while viewing a workflow (nav + full-width editor) */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-y-auto p-4",
|
||||
selectedWorkflowId
|
||||
? "hidden"
|
||||
: "min-h-0 w-full flex-1 lg:w-52 lg:max-w-xs lg:shrink-0 lg:border-border lg:border-r",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="mb-4 flex items-center justify-between"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">Workflows</h2>
|
||||
<Button
|
||||
@@ -192,6 +209,7 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
|
||||
onEdit={handleEdit}
|
||||
onSelect={setSelectedWorkflowId}
|
||||
onTrigger={handleTrigger}
|
||||
selected={selectedWorkflowId === workflow.id}
|
||||
workflow={workflow}
|
||||
/>
|
||||
))}
|
||||
@@ -200,9 +218,10 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
|
||||
</div>
|
||||
|
||||
{selectedWorkflowId ? (
|
||||
<div className="w-[400px] shrink-0">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<WorkflowDetailPanel
|
||||
key={selectedWorkflowId}
|
||||
channelName={selectedChannelName}
|
||||
onClose={() => setSelectedWorkflowId(null)}
|
||||
onEdit={handleEdit}
|
||||
workflowId={selectedWorkflowId}
|
||||
|
||||
@@ -2,6 +2,47 @@ import type { Workflow } from "@/shared/api/types";
|
||||
import { TRIGGER_LABELS } from "./workflowFormTypes";
|
||||
import type { TriggerType } from "./workflowFormTypes";
|
||||
|
||||
const DISPLAY_TITLE_MAX = 100;
|
||||
|
||||
/** Stable name from YAML / API (may be snake_case). */
|
||||
export function getWorkflowDefinitionName(workflow: Workflow): string {
|
||||
const fromDef = workflow.definition?.name;
|
||||
if (typeof fromDef === "string" && fromDef.trim().length > 0) {
|
||||
return fromDef.trim();
|
||||
}
|
||||
return workflow.name.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns stored workflow names into a readable header/card title.
|
||||
* Pass-through for names that already look like sentences or titles; otherwise
|
||||
* converts snake_case to Title Case words.
|
||||
*/
|
||||
export function humanizeWorkflowTitle(raw: string): string {
|
||||
const name = raw.trim();
|
||||
if (!name) {
|
||||
return "Untitled workflow";
|
||||
}
|
||||
if (name.length > DISPLAY_TITLE_MAX) {
|
||||
return `${name.slice(0, DISPLAY_TITLE_MAX - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
if (name.includes(" ") || !name.includes("_")) {
|
||||
return name;
|
||||
}
|
||||
|
||||
return name
|
||||
.split("_")
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** Title shown in headers, cards, and dialogs. */
|
||||
export function getWorkflowDisplayTitle(workflow: Workflow): string {
|
||||
return humanizeWorkflowTitle(getWorkflowDefinitionName(workflow));
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
|
||||
179
desktop/src/features/workflows/ui/workflowDefinitionNodeInfo.ts
Normal file
179
desktop/src/features/workflows/ui/workflowDefinitionNodeInfo.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
ACTION_LABELS,
|
||||
TRIGGER_LABELS,
|
||||
type ActionType,
|
||||
type TriggerType,
|
||||
} from "./workflowFormTypes";
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
function truncateText(value: string | null, maxLength = 72): string | null {
|
||||
if (!value) return null;
|
||||
return value.length > maxLength
|
||||
? `${value.slice(0, maxLength - 1)}...`
|
||||
: value;
|
||||
}
|
||||
|
||||
export function getTriggerTitle(
|
||||
trigger: Record<string, unknown> | null,
|
||||
): string {
|
||||
const on = asString(trigger?.on);
|
||||
if (!on) return "Trigger";
|
||||
return TRIGGER_LABELS[on as TriggerType] ?? on;
|
||||
}
|
||||
|
||||
export function getTriggerDetails(
|
||||
trigger: Record<string, unknown> | null,
|
||||
): string[] {
|
||||
if (!trigger) return [];
|
||||
|
||||
const on = asString(trigger.on);
|
||||
const details: string[] = [];
|
||||
|
||||
if (on === "message_posted" || on === "diff_posted") {
|
||||
const filter = asString(trigger.filter);
|
||||
if (filter) details.push(`Filter: ${truncateText(filter)}`);
|
||||
}
|
||||
|
||||
if (on === "reaction_added") {
|
||||
const emoji = asString(trigger.emoji);
|
||||
if (emoji) details.push(`Emoji: :${emoji}:`);
|
||||
}
|
||||
|
||||
if (on === "schedule") {
|
||||
const cron = asString(trigger.cron);
|
||||
const interval = asString(trigger.interval);
|
||||
if (cron) details.push(`Cron: ${truncateText(cron)}`);
|
||||
if (interval) details.push(`Interval: ${interval}`);
|
||||
}
|
||||
|
||||
if (on === "webhook") {
|
||||
details.push("Incoming HTTP webhook");
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
export function getStepTitle(
|
||||
step: Record<string, unknown>,
|
||||
index: number,
|
||||
): string {
|
||||
const name = asString(step.name);
|
||||
if (name) return name;
|
||||
|
||||
const action = asString(step.action);
|
||||
if (action) {
|
||||
return ACTION_LABELS[action as ActionType] ?? action;
|
||||
}
|
||||
|
||||
return `Step ${index + 1}`;
|
||||
}
|
||||
|
||||
export function getStepDetails(step: Record<string, unknown>): string[] {
|
||||
const details: string[] = [];
|
||||
const action = asString(step.action);
|
||||
const condition = asString(step.if);
|
||||
const timeoutSecs = asString(step.timeout_secs);
|
||||
|
||||
if (condition) details.push(`If: ${truncateText(condition)}`);
|
||||
if (timeoutSecs) details.push(`Timeout: ${timeoutSecs}s`);
|
||||
|
||||
switch (action) {
|
||||
case "delay": {
|
||||
const duration = asString(step.duration);
|
||||
if (duration) details.push(`Wait: ${duration}`);
|
||||
break;
|
||||
}
|
||||
case "send_message": {
|
||||
const text = truncateText(asString(step.text));
|
||||
const channel = asString(step.channel);
|
||||
if (text) details.push(`Message: ${text}`);
|
||||
if (channel) details.push(`Channel: ${channel}`);
|
||||
break;
|
||||
}
|
||||
case "send_dm": {
|
||||
const to = asString(step.to);
|
||||
const text = truncateText(asString(step.text));
|
||||
if (to) details.push(`To: ${to}`);
|
||||
if (text) details.push(`Message: ${text}`);
|
||||
break;
|
||||
}
|
||||
case "call_webhook": {
|
||||
const method = asString(step.method) ?? "POST";
|
||||
const url = truncateText(asString(step.url));
|
||||
details.push(`Method: ${method}`);
|
||||
if (url) details.push(`URL: ${url}`);
|
||||
break;
|
||||
}
|
||||
case "request_approval": {
|
||||
const from = asString(step.from);
|
||||
const message = truncateText(asString(step.message));
|
||||
const timeout = asString(step.timeout);
|
||||
if (from) details.push(`Approver: ${from}`);
|
||||
if (message) details.push(`Message: ${message}`);
|
||||
if (timeout) details.push(`Timeout: ${timeout}`);
|
||||
break;
|
||||
}
|
||||
case "add_reaction": {
|
||||
const emoji = asString(step.emoji);
|
||||
if (emoji) details.push(`Emoji: :${emoji}:`);
|
||||
break;
|
||||
}
|
||||
case "set_channel_topic": {
|
||||
const topic = truncateText(asString(step.topic));
|
||||
if (topic) details.push(`Topic: ${topic}`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
export type ResolvedGraphSelection =
|
||||
| { kind: "none" }
|
||||
| { kind: "trigger"; trigger: Record<string, unknown> | null }
|
||||
| {
|
||||
kind: "step";
|
||||
index: number;
|
||||
step: Record<string, unknown>;
|
||||
stepId: string;
|
||||
};
|
||||
|
||||
/** Graph node ids are `trigger` or `step-${stepId}` (same ids as React Flow nodes). */
|
||||
export function resolveGraphNodeSelection(
|
||||
definition: Record<string, unknown>,
|
||||
selectedNodeId: string | null,
|
||||
): ResolvedGraphSelection {
|
||||
if (!selectedNodeId) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
if (selectedNodeId === "trigger") {
|
||||
return { kind: "trigger", trigger: asRecord(definition.trigger) };
|
||||
}
|
||||
if (selectedNodeId.startsWith("step-")) {
|
||||
const targetStepId = selectedNodeId.slice("step-".length);
|
||||
const rawSteps = Array.isArray(definition.steps) ? definition.steps : [];
|
||||
for (let i = 0; i < rawSteps.length; i++) {
|
||||
const step = asRecord(rawSteps[i]);
|
||||
if (!step) continue;
|
||||
const sid = asString(step.id) ?? `step_${i + 1}`;
|
||||
if (sid === targetStepId) {
|
||||
return { kind: "step", index: i, step, stepId: sid };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { kind: "none" };
|
||||
}
|
||||
@@ -93,6 +93,10 @@ export const ACTION_LABELS: Record<ActionType, string> = {
|
||||
set_channel_topic: "Set Channel Topic",
|
||||
};
|
||||
|
||||
function hasNonEmptyText(value: string | undefined): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function toHeaderRows(
|
||||
headers: unknown,
|
||||
stepId: string,
|
||||
@@ -217,6 +221,25 @@ export function formStateToYaml(state: WorkflowFormState): string {
|
||||
return yamlStringify(workflow);
|
||||
}
|
||||
|
||||
export function getWorkflowFormValidationError(
|
||||
state: WorkflowFormState,
|
||||
): string | null {
|
||||
if (state.trigger.on === "schedule") {
|
||||
const hasCron = hasNonEmptyText(state.trigger.cron);
|
||||
const hasInterval = hasNonEmptyText(state.trigger.interval);
|
||||
|
||||
if (!hasCron && !hasInterval) {
|
||||
return "Schedule trigger requires either a cron expression or an interval.";
|
||||
}
|
||||
|
||||
if (hasCron && hasInterval) {
|
||||
return "Schedule trigger cannot include both a cron expression and an interval.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const STEP_ID_PATTERN = /^step_(\d+)$/;
|
||||
|
||||
export function nextStepId(existingSteps: StepFormState[]): string {
|
||||
@@ -315,3 +338,9 @@ export function yamlToFormState(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowYamlValidationError(yaml: string): string | null {
|
||||
const result = yamlToFormState(yaml);
|
||||
if (!result.ok) return null;
|
||||
return getWorkflowFormValidationError(result.state);
|
||||
}
|
||||
|
||||
361
desktop/src/features/workflows/ui/workflowPromptScaffold.ts
Normal file
361
desktop/src/features/workflows/ui/workflowPromptScaffold.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import type {
|
||||
StepFormState,
|
||||
TriggerConfig,
|
||||
WorkflowFormState,
|
||||
} from "./workflowFormTypes";
|
||||
import { formStateToYaml } from "./workflowFormTypes";
|
||||
|
||||
const URL_PATTERN = /https:\/\/[^\s"')]+/i;
|
||||
const QUOTED_TEXT_PATTERN = /"([^"\n]+)"|'([^'\n]+)'/g;
|
||||
const EMOJI_PATTERN = /:([a-z0-9_+-]+):/i;
|
||||
const EVERY_INTERVAL_PATTERN =
|
||||
/\bevery\s+(\d+)\s*(minute|minutes|hour|hours|day|days|week|weeks)\b/i;
|
||||
const WAIT_INTERVAL_PATTERN =
|
||||
/\b(?:wait|delay)\s+(?:for\s+)?(\d+)\s*(second|seconds|minute|minutes|hour|hours|day|days)\b/i;
|
||||
|
||||
function toSnakeCase(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.replace(/_+/g, "_");
|
||||
}
|
||||
|
||||
function collectQuotedTexts(prompt: string): string[] {
|
||||
const matches: string[] = [];
|
||||
for (const match of prompt.matchAll(QUOTED_TEXT_PATTERN)) {
|
||||
const value = match[1] ?? match[2] ?? "";
|
||||
if (value.trim()) {
|
||||
matches.push(value.trim());
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function firstNonEmptyMatch(match: RegExpMatchArray | null, index: number) {
|
||||
const value = match?.[index]?.trim();
|
||||
return value ? value : null;
|
||||
}
|
||||
|
||||
function normalizeInterval(value: string, unit: string): string {
|
||||
const trimmedUnit = unit.toLowerCase();
|
||||
if (trimmedUnit.startsWith("second")) return `${value}s`;
|
||||
if (trimmedUnit.startsWith("minute")) return `${value}m`;
|
||||
if (trimmedUnit.startsWith("hour")) return `${value}h`;
|
||||
if (trimmedUnit.startsWith("day")) return `${value}d`;
|
||||
if (trimmedUnit.startsWith("week")) return `${Number(value) * 7}d`;
|
||||
return `${value}h`;
|
||||
}
|
||||
|
||||
function inferScheduleInterval(prompt: string): string | undefined {
|
||||
const everyMatch = prompt.match(EVERY_INTERVAL_PATTERN);
|
||||
if (everyMatch) {
|
||||
return normalizeInterval(everyMatch[1], everyMatch[2]);
|
||||
}
|
||||
|
||||
const lower = prompt.toLowerCase();
|
||||
if (lower.includes("hourly") || lower.includes("every hour")) {
|
||||
return "1h";
|
||||
}
|
||||
if (lower.includes("daily") || lower.includes("every day")) {
|
||||
return "1d";
|
||||
}
|
||||
if (lower.includes("weekly") || lower.includes("every week")) {
|
||||
return "7d";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function inferDelayDuration(prompt: string): string | undefined {
|
||||
const waitMatch = prompt.match(WAIT_INTERVAL_PATTERN);
|
||||
if (waitMatch) {
|
||||
return normalizeInterval(waitMatch[1], waitMatch[2]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function inferEmoji(prompt: string): string | undefined {
|
||||
const explicitEmoji = firstNonEmptyMatch(prompt.match(EMOJI_PATTERN), 1);
|
||||
if (explicitEmoji) {
|
||||
return explicitEmoji;
|
||||
}
|
||||
|
||||
const lower = prompt.toLowerCase();
|
||||
const namedEmojiMatches: Array<[string, string]> = [
|
||||
["thumbs up", "thumbsup"],
|
||||
["thumbsup", "thumbsup"],
|
||||
["thumbsdown", "thumbsdown"],
|
||||
["eyes", "eyes"],
|
||||
["rocket", "rocket"],
|
||||
["check", "white_check_mark"],
|
||||
];
|
||||
|
||||
for (const [needle, emoji] of namedEmojiMatches) {
|
||||
if (lower.includes(needle)) {
|
||||
return emoji;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function inferTopic(prompt: string): string | undefined {
|
||||
const quotedTexts = collectQuotedTexts(prompt);
|
||||
if (quotedTexts.length > 0) {
|
||||
return quotedTexts[0];
|
||||
}
|
||||
|
||||
const match = prompt.match(/\btopic(?: to| as)?\s+([a-z0-9 _-]+)/i);
|
||||
return firstNonEmptyMatch(match, 1) ?? undefined;
|
||||
}
|
||||
|
||||
function inferFilterTerm(prompt: string): string | undefined {
|
||||
const quotedTexts = collectQuotedTexts(prompt);
|
||||
if (quotedTexts.length > 0) {
|
||||
return quotedTexts[0];
|
||||
}
|
||||
|
||||
const simpleMessageMatch = prompt.match(
|
||||
/\b(?:posts?|says?|contains?|mentions?)\s+([a-z0-9._/-]+)/i,
|
||||
);
|
||||
return firstNonEmptyMatch(simpleMessageMatch, 1) ?? undefined;
|
||||
}
|
||||
|
||||
function asContainsFilter(term: string | undefined): string | undefined {
|
||||
if (!term) return undefined;
|
||||
const normalized = term.replace(/\\/g, "\\\\").replace(/"/g, '\\"').trim();
|
||||
return normalized ? `contains(text, "${normalized}")` : undefined;
|
||||
}
|
||||
|
||||
function inferTrigger(prompt: string): TriggerConfig {
|
||||
const lower = prompt.toLowerCase();
|
||||
const filter = asContainsFilter(inferFilterTerm(prompt));
|
||||
|
||||
const incomingWebhook =
|
||||
/\b(incoming webhook|when a webhook|when webhook|webhook trigger)\b/i.test(
|
||||
prompt,
|
||||
) || /\breceive\b.*\bwebhook\b/i.test(prompt);
|
||||
if (incomingWebhook) {
|
||||
return { on: "webhook" };
|
||||
}
|
||||
|
||||
const looksScheduled =
|
||||
/\b(schedule|scheduled|every|hourly|daily|weekly|cron)\b/i.test(prompt);
|
||||
if (looksScheduled) {
|
||||
return {
|
||||
on: "schedule",
|
||||
interval: inferScheduleInterval(prompt),
|
||||
};
|
||||
}
|
||||
|
||||
if (/\b(reaction|emoji)\b/i.test(prompt)) {
|
||||
return {
|
||||
on: "reaction_added",
|
||||
emoji: inferEmoji(prompt),
|
||||
};
|
||||
}
|
||||
|
||||
if (/\b(diff|pull request|pr\b|patch)\b/i.test(lower)) {
|
||||
return {
|
||||
on: "diff_posted",
|
||||
filter,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
on: "message_posted",
|
||||
filter,
|
||||
};
|
||||
}
|
||||
|
||||
const INFERRED_TITLE_MAX = 80;
|
||||
|
||||
/**
|
||||
* Human-readable workflow title for YAML `name` (shown in the app header).
|
||||
* Prefers the first line of the user’s prompt; falls back to short template labels.
|
||||
*/
|
||||
function inferWorkflowName(prompt: string, trigger: TriggerConfig): string {
|
||||
const lower = prompt.toLowerCase();
|
||||
const firstLine = prompt.trim().split(/\r?\n/)[0]?.trim() ?? "";
|
||||
|
||||
if (trigger.on === "webhook") return "Incoming webhook";
|
||||
if (trigger.on === "schedule") return "Scheduled workflow";
|
||||
if (trigger.on === "reaction_added") return "Reaction workflow";
|
||||
if (trigger.on === "diff_posted") return "Diff review";
|
||||
|
||||
if (lower.includes("deploy")) return "Deploy notifications";
|
||||
if (lower.includes("alert")) return "Alert workflow";
|
||||
if (lower.includes("remind")) return "Reminder workflow";
|
||||
if (lower.includes("notify")) return "Notification workflow";
|
||||
|
||||
if (firstLine.length > 0) {
|
||||
let title = firstLine.replace(/\s+/g, " ");
|
||||
if (title.length > INFERRED_TITLE_MAX) {
|
||||
title = `${title.slice(0, INFERRED_TITLE_MAX - 1).trimEnd()}…`;
|
||||
}
|
||||
return title.charAt(0).toUpperCase() + title.slice(1);
|
||||
}
|
||||
|
||||
const fallback = toSnakeCase(prompt).slice(0, 48);
|
||||
return fallback ? humanizeSnakeFallback(fallback) : "New workflow";
|
||||
}
|
||||
|
||||
/** Last resort when prompt yields only a slug — still nicer than raw snake_case in UI. */
|
||||
function humanizeSnakeFallback(slug: string): string {
|
||||
return slug
|
||||
.split("_")
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function buildMessageStep(
|
||||
trigger: TriggerConfig,
|
||||
prompt: string,
|
||||
defaultChannelId?: string,
|
||||
): StepFormState {
|
||||
const filterTerm = inferFilterTerm(prompt);
|
||||
|
||||
let text = "Workflow triggered.";
|
||||
if (trigger.on === "webhook") {
|
||||
text = "Webhook received and processed.";
|
||||
} else if (trigger.on === "schedule") {
|
||||
text = "Scheduled workflow ran.";
|
||||
} else if (trigger.on === "reaction_added") {
|
||||
text = trigger.emoji
|
||||
? `Reaction :${trigger.emoji}: detected.`
|
||||
: "A reaction triggered this workflow.";
|
||||
} else if (trigger.on === "diff_posted") {
|
||||
text = filterTerm
|
||||
? `Diff matched "${filterTerm}".`
|
||||
: "A diff triggered this workflow.";
|
||||
} else if (filterTerm) {
|
||||
text = `Detected "${filterTerm}" in a message.`;
|
||||
} else {
|
||||
text = "A new message triggered this workflow.";
|
||||
}
|
||||
|
||||
return {
|
||||
id: "step_1",
|
||||
name: "Notify channel",
|
||||
action: "send_message",
|
||||
channel: trigger.on === "webhook" ? defaultChannelId : undefined,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrimaryActionStep(
|
||||
prompt: string,
|
||||
trigger: TriggerConfig,
|
||||
defaultChannelId?: string,
|
||||
): StepFormState {
|
||||
const lower = prompt.toLowerCase();
|
||||
const url = firstNonEmptyMatch(prompt.match(URL_PATTERN), 0);
|
||||
|
||||
if (
|
||||
/\b(call|post|send|forward|hit|invoke)\b.*\bwebhook\b/i.test(prompt) ||
|
||||
(url !== null && trigger.on !== "webhook")
|
||||
) {
|
||||
return {
|
||||
id: "step_1",
|
||||
name: "Call webhook",
|
||||
action: "call_webhook",
|
||||
url: url ?? "https://example.com/webhook",
|
||||
method: "POST",
|
||||
body: '{"text":"{{trigger.text}}"}',
|
||||
};
|
||||
}
|
||||
|
||||
if (/\b(direct message| dm |^dm\b|\bdm\b)\b/i.test(` ${lower} `)) {
|
||||
return {
|
||||
id: "step_1",
|
||||
name: "Send direct message",
|
||||
action: "send_dm",
|
||||
to: "{{trigger.author}}",
|
||||
text: "Workflow triggered.",
|
||||
};
|
||||
}
|
||||
|
||||
if (/\b(approve|approval)\b/i.test(prompt)) {
|
||||
return {
|
||||
id: "step_1",
|
||||
name: "Request approval",
|
||||
action: "request_approval",
|
||||
from: "channel_admins",
|
||||
message: "Approve this workflow run.",
|
||||
timeout: "24h",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
/\b(add reaction|react with|react using|leave a reaction)\b/i.test(prompt)
|
||||
) {
|
||||
return {
|
||||
id: "step_1",
|
||||
name: "Add reaction",
|
||||
action: "add_reaction",
|
||||
emoji: inferEmoji(prompt) ?? "eyes",
|
||||
};
|
||||
}
|
||||
|
||||
if (/\b(set|change|update)\b.*\btopic\b/i.test(prompt)) {
|
||||
return {
|
||||
id: "step_1",
|
||||
name: "Set channel topic",
|
||||
action: "set_channel_topic",
|
||||
topic: inferTopic(prompt) ?? "Workflow updated topic",
|
||||
};
|
||||
}
|
||||
|
||||
return buildMessageStep(trigger, prompt, defaultChannelId);
|
||||
}
|
||||
|
||||
function buildSteps(
|
||||
prompt: string,
|
||||
trigger: TriggerConfig,
|
||||
defaultChannelId?: string,
|
||||
): StepFormState[] {
|
||||
const delayDuration = inferDelayDuration(prompt);
|
||||
const primary = buildPrimaryActionStep(prompt, trigger, defaultChannelId);
|
||||
|
||||
if (!delayDuration) {
|
||||
return [primary];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: "step_1",
|
||||
name: "Wait",
|
||||
action: "delay",
|
||||
duration: delayDuration,
|
||||
},
|
||||
{
|
||||
...primary,
|
||||
id: "step_2",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function draftWorkflowFromPrompt(
|
||||
prompt: string,
|
||||
options?: { defaultChannelId?: string },
|
||||
): WorkflowFormState {
|
||||
const trimmedPrompt = prompt.trim();
|
||||
const trigger = inferTrigger(trimmedPrompt);
|
||||
|
||||
return {
|
||||
name: inferWorkflowName(trimmedPrompt, trigger),
|
||||
description: trimmedPrompt,
|
||||
enabled: true,
|
||||
trigger,
|
||||
steps: buildSteps(trimmedPrompt, trigger, options?.defaultChannelId),
|
||||
};
|
||||
}
|
||||
|
||||
export function draftWorkflowYamlFromPrompt(
|
||||
prompt: string,
|
||||
options?: { defaultChannelId?: string },
|
||||
): string {
|
||||
return formStateToYaml(draftWorkflowFromPrompt(prompt, options));
|
||||
}
|
||||
@@ -12,12 +12,50 @@ async function navigateToWorkflows(page: import("@playwright/test").Page) {
|
||||
await expect(page.getByTestId("workflows-view")).toBeVisible();
|
||||
}
|
||||
|
||||
/** Matches UI humanizeWorkflowTitle for typical test_* snake_case names */
|
||||
function displayWorkflowTitle(storedName: string): string {
|
||||
const name = storedName.trim();
|
||||
if (!name) {
|
||||
return name;
|
||||
}
|
||||
if (name.length > 100) {
|
||||
return `${name.slice(0, 99)}…`;
|
||||
}
|
||||
if (name.includes(" ") || !name.includes("_")) {
|
||||
return name;
|
||||
}
|
||||
return name
|
||||
.split("_")
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function defaultWorkflowPrompt(
|
||||
name: string,
|
||||
trigger: string | undefined,
|
||||
): string {
|
||||
switch (trigger) {
|
||||
case "webhook":
|
||||
return "When an incoming webhook arrives, send a message to the channel.";
|
||||
case "diff_posted":
|
||||
return `When a diff mentions "${name}", send a message to the channel.`;
|
||||
case "reaction_added":
|
||||
return "When someone adds a :thumbsup: reaction, send a message.";
|
||||
case "schedule":
|
||||
return "Every hour, send a message to the channel.";
|
||||
default:
|
||||
return `When someone posts "${name}", send a message to the channel.`;
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorkflow(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
options?: {
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
prompt?: string;
|
||||
trigger?: string;
|
||||
stepCondition?: string;
|
||||
stepName?: string;
|
||||
@@ -28,7 +66,14 @@ async function createWorkflow(
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.getByLabel("Workflow name").fill(name);
|
||||
await dialog
|
||||
.getByLabel("Describe the workflow")
|
||||
.fill(options?.prompt ?? defaultWorkflowPrompt(name, options?.trigger));
|
||||
await dialog.getByRole("button", { name: "Draft workflow" }).click();
|
||||
await expect(dialog.getByLabel("Workflow title")).toBeVisible();
|
||||
|
||||
await dialog.getByLabel("Workflow title").clear();
|
||||
await dialog.getByLabel("Workflow title").fill(name);
|
||||
if (options?.description) {
|
||||
await dialog.getByLabel("Description (optional)").fill(options.description);
|
||||
}
|
||||
@@ -39,7 +84,6 @@ async function createWorkflow(
|
||||
await dialog.getByLabel("Trigger").selectOption(options.trigger);
|
||||
}
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||||
if (options?.stepName) {
|
||||
await dialog.getByLabel("Step name (optional)").fill(options.stepName);
|
||||
}
|
||||
@@ -77,7 +121,29 @@ test("creates a workflow via the form builder", async ({ page }) => {
|
||||
await createWorkflow(page, workflowName);
|
||||
|
||||
// Verify workflow appears in the list
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(
|
||||
displayWorkflowTitle(workflowName),
|
||||
);
|
||||
});
|
||||
|
||||
test("starts workflow creation with a prompt before showing setup", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateToWorkflows(page);
|
||||
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
|
||||
await expect(dialog.getByLabel("Describe the workflow")).toBeVisible();
|
||||
await expect(dialog.getByLabel("Workflow title")).not.toBeVisible();
|
||||
|
||||
await dialog
|
||||
.getByLabel("Describe the workflow")
|
||||
.fill('When someone posts "deploy", send a message.');
|
||||
await dialog.getByRole("button", { name: "Draft workflow" }).click();
|
||||
|
||||
await expect(dialog.getByLabel("Workflow title")).toBeVisible();
|
||||
await expect(dialog.getByText("Drafted from your prompt")).toBeVisible();
|
||||
});
|
||||
|
||||
test("disables autocapitalization in the workflow form", async ({ page }) => {
|
||||
@@ -86,12 +152,16 @@ test("disables autocapitalization in the workflow form", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Create Workflow" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
|
||||
await expect(dialog.getByLabel("Workflow name")).toHaveAttribute(
|
||||
await dialog
|
||||
.getByLabel("Describe the workflow")
|
||||
.fill('When someone posts "deploy", send a message.');
|
||||
await dialog.getByRole("button", { name: "Draft workflow" }).click();
|
||||
|
||||
await expect(dialog.getByLabel("Workflow title")).toHaveAttribute(
|
||||
"autocapitalize",
|
||||
"off",
|
||||
);
|
||||
|
||||
await dialog.getByRole("button", { name: "Add step" }).click();
|
||||
await expect(dialog.getByLabel("Step name (optional)")).toHaveAttribute(
|
||||
"autocapitalize",
|
||||
"off",
|
||||
@@ -114,9 +184,9 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => {
|
||||
|
||||
const card = page
|
||||
.locator('[data-testid^="workflow-card-"]')
|
||||
.filter({ hasText: workflowName })
|
||||
.filter({ hasText: displayWorkflowTitle(workflowName) })
|
||||
.first();
|
||||
await expect(card).toContainText(workflowName);
|
||||
await expect(card).toContainText(displayWorkflowTitle(workflowName));
|
||||
await expect(card).toContainText(description);
|
||||
await expect(card).toContainText("Diff Posted");
|
||||
await expect(card).toContainText("disabled");
|
||||
@@ -148,7 +218,9 @@ test("edits an existing workflow", async ({ page }) => {
|
||||
await createWorkflow(page, originalName);
|
||||
|
||||
// Verify it exists
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(originalName);
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(
|
||||
displayWorkflowTitle(originalName),
|
||||
);
|
||||
|
||||
// Open the dropdown menu and click Edit
|
||||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||||
@@ -159,7 +231,7 @@ test("edits an existing workflow", async ({ page }) => {
|
||||
await expect(page.getByText("Edit Workflow")).toBeVisible();
|
||||
|
||||
// Change the name
|
||||
const nameInput = page.getByLabel("Workflow name");
|
||||
const nameInput = page.getByLabel("Workflow title");
|
||||
await nameInput.clear();
|
||||
await nameInput.fill(updatedName);
|
||||
|
||||
@@ -168,7 +240,9 @@ test("edits an existing workflow", async ({ page }) => {
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
|
||||
// Verify the updated name appears
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(updatedName);
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(
|
||||
displayWorkflowTitle(updatedName),
|
||||
);
|
||||
});
|
||||
|
||||
test("duplicates a workflow", async ({ page }) => {
|
||||
@@ -190,7 +264,12 @@ test("duplicates a workflow", async ({ page }) => {
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
|
||||
// Both the original and copy should exist
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(originalName);
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(
|
||||
displayWorkflowTitle(originalName),
|
||||
);
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(
|
||||
`${originalName} (copy)`,
|
||||
);
|
||||
});
|
||||
|
||||
test("deletes a workflow with confirmation", async ({ page }) => {
|
||||
@@ -200,7 +279,9 @@ test("deletes a workflow with confirmation", async ({ page }) => {
|
||||
await createWorkflow(page, workflowName);
|
||||
|
||||
// Verify it exists
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
|
||||
await expect(page.getByTestId("workflows-view")).toContainText(
|
||||
displayWorkflowTitle(workflowName),
|
||||
);
|
||||
|
||||
// Open the dropdown menu and click Delete
|
||||
await page.getByRole("button", { name: "Workflow actions" }).first().click();
|
||||
@@ -208,7 +289,9 @@ test("deletes a workflow with confirmation", async ({ page }) => {
|
||||
|
||||
// Confirmation dialog should appear with workflow name
|
||||
await expect(page.getByRole("alertdialog")).toBeVisible();
|
||||
await expect(page.getByRole("alertdialog")).toContainText(workflowName);
|
||||
await expect(page.getByRole("alertdialog")).toContainText(
|
||||
displayWorkflowTitle(workflowName),
|
||||
);
|
||||
|
||||
// Confirm deletion
|
||||
await page.getByRole("button", { name: "Delete" }).click();
|
||||
@@ -225,7 +308,9 @@ test("triggers a workflow from the detail panel", async ({ page }) => {
|
||||
await createWorkflow(page, workflowName);
|
||||
|
||||
// Click on the workflow card to open the detail panel
|
||||
await page.getByRole("button", { name: `View ${workflowName}` }).click();
|
||||
await page
|
||||
.getByRole("button", { name: `View ${displayWorkflowTitle(workflowName)}` })
|
||||
.click();
|
||||
await expect(page.getByTestId("workflow-detail-panel")).toBeVisible();
|
||||
|
||||
// Click the Trigger button
|
||||
|
||||
Reference in New Issue
Block a user