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:
Thomas Petersen
2026-04-08 22:35:28 +02:00
parent da7ded5425
commit 4f361dfca6
18 changed files with 2067 additions and 362 deletions

View File

@@ -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
View File

@@ -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: {}

View File

@@ -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">

View 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>
);
}

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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>

View 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&apos;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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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;

View 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" };
}

View File

@@ -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);
}

View 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));
}

View File

@@ -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