From 363cd97f9a12122f660805c3b392eaf8d25efba8 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 7 May 2026 21:04:10 +0000 Subject: [PATCH] update --- frontend/package-lock.json | 50 +------ .../assets/AssetGraph/AssetGraphCanvas.svelte | 22 ++- .../AssetGraph/AssetGraphDetailsPane.svelte | 26 +++- .../assets/AssetGraph/RunnableNode.svelte | 63 ++++++++- .../assets/AssetGraph/pipelineTemplates.ts | 133 +++++++++++++----- .../(logged)/pipeline/[folder]/+page.svelte | 22 +++ 6 files changed, 225 insertions(+), 91 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2e8ca86313..0dc39f40c0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -844,7 +844,6 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -856,7 +855,6 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -867,7 +865,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1357,7 +1354,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1514,7 +1510,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1531,7 +1526,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1548,7 +1542,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1565,7 +1558,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1582,7 +1574,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1599,7 +1590,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1616,7 +1606,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1633,7 +1622,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1650,7 +1638,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1667,7 +1654,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1684,7 +1670,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1701,7 +1686,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1718,7 +1702,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1735,7 +1718,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1752,7 +1734,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2058,7 +2039,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6834,7 +6814,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7333,7 +7313,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7354,7 +7333,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7375,7 +7353,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7396,7 +7373,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7417,7 +7393,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7438,7 +7413,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7459,7 +7433,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7480,7 +7453,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7501,7 +7473,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7522,7 +7493,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7543,7 +7513,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12112,21 +12081,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -12857,7 +12811,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index fb7def7523..32c507335d 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -70,6 +70,15 @@ path: string unsaved?: boolean }) => Promise + // Page-supplied dispatch for the per-runnable-node action menu. + // Drafts are discarded immediately by the page; persisted scripts + // are routed through the details pane's archive/delete confirmation + // flow. Without this callback, the node menu button is hidden. + onRunnableMenuRemove?: (info: { + runnable_kind: 'script' | 'flow' + path: string + unsaved?: boolean + }) => void } let { graph, @@ -80,7 +89,8 @@ pathPrefix = '', defaultPathSuffix = '', defaultScheduleCron = '', - onRunProducer + onRunProducer, + onRunnableMenuRemove }: Props = $props() const ADD_NODE_ID = '__add__' @@ -178,7 +188,15 @@ in_pipeline: r.in_pipeline ?? false, partition_kind: r.partition_kind, freshness: r.freshness, - unsaved: r.unsaved ?? false + unsaved: r.unsaved ?? false, + onRequestRemove: onRunnableMenuRemove + ? () => + onRunnableMenuRemove({ + runnable_kind: r.usage_kind, + path: r.path, + unsaved: r.unsaved ?? false + }) + : undefined } }) } diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte index 119e3051ec..3331827abc 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte @@ -85,6 +85,12 @@ // true on success; false on collision/validation failure so the // popover can keep itself open and surface the error inline. onDraftPathChange?: (oldPath: string, newPath: string) => boolean | string + // Bumped by the parent (e.g. from the runnable-node action menu) to + // auto-open the archive/delete confirmation modal for the currently + // loaded persisted script. No-ops while the pane is showing a draft + // or is still loading. Counter pattern (not a boolean) so successive + // triggers re-fire even if the modal was just closed. + requestRemoveSignal?: number } let { selection, @@ -100,7 +106,8 @@ runsRefreshKey, runsPendingJobId, pathPrefix = '', - onDraftPathChange + onDraftPathChange, + requestRemoveSignal }: Props = $props() // Bumped when the runs panel reports a watched job has reached a @@ -147,6 +154,21 @@ let removing = $state(false) let canHardDelete = $derived(!!($userStore?.is_admin || $userStore?.is_super_admin)) + // React to the parent's remove-signal counter and pop the same modal + // the in-pane trash button uses. Skipped for drafts (the parent calls + // `onDiscard` directly there) and while the script hasn't loaded. + // Counter pattern (not boolean) so successive triggers re-fire even if + // the modal was just closed without acting. + let lastRemoveSignal = $state(undefined) + $effect(() => { + if (requestRemoveSignal === undefined) return + if (requestRemoveSignal === lastRemoveSignal) return + lastRemoveSignal = requestRemoveSignal + if (isDraft) return + if (!script || !script.hash) return + removeOpen = true + }) + async function archive() { if (!script || !script.hash) return removing = true @@ -527,7 +549,7 @@ Failed to load: {scriptRes.error.message} {:else if script} - {#key (script.hash ?? 'draft') + script.language} + {#key (script.hash ?? `draft:${script.path}`) + script.language} import { Handle, Position } from '@xyflow/svelte' - import { Code2, GitBranch, Layers, Timer } from 'lucide-svelte' + import { Code2, EllipsisVertical, GitBranch, Layers, Timer, Trash2 } from 'lucide-svelte' import { twMerge } from 'tailwind-merge' + import { preventDefault, stopPropagation } from 'svelte/legacy' import type { GraphUsageKind } from './types' import { NODE } from '$lib/components/graph/util' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import type { Item } from '$lib/utils' interface Props { data: { @@ -15,6 +18,11 @@ // True for nodes synthesized from local drafts (script not yet // persisted). Same convention as `unsaved` on triggers/edges. unsaved?: boolean + // Wired by the canvas. When set, the node renders an + // EllipsisVertical hover-button that opens a small action menu — + // "Discard" for drafts, "Delete…" (which the page maps to its + // archive/delete confirmation flow) for persisted scripts. + onRequestRemove?: () => void } } let { data }: Props = $props() @@ -30,9 +38,26 @@ ? `${data.path} (pipeline member)` : data.path ) + + let hover = $state(false) + let menuOpen = $state(false) + + let menuItems: Item[] = $derived( + data.onRequestRemove + ? [ + { + displayName: data.unsaved ? 'Discard' : 'Delete…', + icon: Trash2, + type: 'delete' as const, + action: () => data.onRequestRemove?.() + } + ] + : [] + ) -
+ +
(hover = true)} onmouseleave={() => (hover = false)}> +
+ + {#snippet buttonReplacement()} + + {/snippet} + +
+ {/if}
diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineTemplates.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineTemplates.ts index ae2d1634f0..2d94a84c93 100644 --- a/frontend/src/lib/components/assets/AssetGraph/pipelineTemplates.ts +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineTemplates.ts @@ -1,4 +1,5 @@ import type { ScriptLang, AssetKind } from '$lib/gen' +import { random_adj } from '$lib/components/random_positive_adjetive' // What kind of asset the new script will produce. Drives the auto-generated // output annotation, the random output path scheme, and the body skeleton. @@ -82,42 +83,70 @@ export function compatibleOutputKinds(lang: ScriptLang): PipelineOutputKind[] { return LANG_COMPATIBILITY[lang] ?? ['none'] } -// Generates the random suffix portion of an auto-named output asset. Same -// 7-char Crockford-ish alphabet we use for script paths so the slugs read -// consistently. -function randomSlug(len = 7): string { +// Short uniqueness backstop appended to the human-readable name, so that two +// drafts created in the same session don't collide on `_`. 4 chars +// of base36 → ~1.7M variants, more than enough alongside the adj×noun prefix. +function shortSlug(len = 4): string { const a = 'abcdefghijklmnopqrstuvwxyz0123456789' let out = '' for (let i = 0; i < len; i++) out += a[Math.floor(Math.random() * a.length)] return out } +// Asset-shaped noun pools. Mirrors the `random_adj() + '_' + namePlaceholder` +// convention used by Path.svelte for scripts/flows/apps, but the noun is +// chosen to read like the thing the asset *is*: a table for datatable / +// ducklake, a dataset / file payload for S3 outputs. +const TABLE_NOUNS = [ + 'table', + 'dataset', + 'records', + 'events', + 'rows', + 'entries', + 'metrics', + 'snapshot' +] +const DATASET_NOUNS = ['dataset', 'snapshot', 'extract', 'export', 'records', 'batch'] +const FILE_NOUNS = ['payload', 'output', 'report', 'extract', 'snapshot', 'dump'] + +function pick(arr: string[]): string { + return arr[Math.floor(Math.random() * arr.length)] +} + // Fully-qualified asset URI for a freshly-created output, given the picked -// kind. The path is auto-generated to land under `pipelines//` for -// s3 / parquet, and uses the more conventional `main/` form for -// datatable / ducklake (matches what the schema/preview UI expects). The -// name suffix is randomized to avoid collisions when the user adds multiple -// downstream scripts in one session. +// kind. Names follow `__` to read like real assets +// (e.g. `nimble_dataset_a3kp`) instead of opaque hashes — matches the +// `random_adj()_*` naming we already use for scripts/flows/apps. Layout: +// - datatable / ducklake → `main/__` (table refs) +// - s3 parquet / object → `pipelines//__.` export function autoOutputAsset( kind: PipelineOutputKind, folder: string, language?: ScriptLang ): { kind: AssetKind; path: string } | undefined { - const slug = randomSlug() + const slug = shortSlug() + const adj = random_adj() switch (kind) { case 'datatable': - return { kind: 'datatable', path: `main/${folder}_${slug}` } + return { kind: 'datatable', path: `main/${adj}_${pick(TABLE_NOUNS)}_${slug}` } case 'ducklake': - return { kind: 'ducklake', path: `main/${folder}_${slug}` } + return { kind: 'ducklake', path: `main/${adj}_${pick(TABLE_NOUNS)}_${slug}` } case 's3_parquet': - return { kind: 's3object', path: `pipelines/${folder}/out_${slug}.parquet` } + return { + kind: 's3object', + path: `pipelines/${folder}/${adj}_${pick(DATASET_NOUNS)}_${slug}.parquet` + } case 's3_object': { // duckdb's natural output for a generic blob is CSV (one COPY TO // statement). TS/Python templates serialize JSON, so default to // .json for those — keeps the body and the file extension in // sync without the user having to rename anything. const ext = language === 'duckdb' ? 'csv' : 'json' - return { kind: 's3object', path: `pipelines/${folder}/out_${slug}.${ext}` } + return { + kind: 's3object', + path: `pipelines/${folder}/${adj}_${pick(FILE_NOUNS)}_${slug}.${ext}` + } } case 'none': return undefined @@ -167,6 +196,19 @@ function qualifiedDatatableRef(p: string, defaultSchema = 'public'): string { return `${parsed.schema ?? defaultSchema}.${parsed.table || 'table_name'}` } +// Catalog-relative table reference. Emits `.` only when the +// asset path explicitly carries a schema (`main/myschema.mytable`); otherwise +// returns the bare `
`. Used inside DuckDB attached-catalog references +// (`pg.`, `lake.`) where the asset parser maps a 2-part name like +// `pg.foo` straight to `datatable://main/foo` — adding a redundant `public.` +// would shift the parsed asset to `datatable://main/public.foo` and silently +// desync from the `// Output: ...` annotation. +function catalogTableRef(p: string): string { + const parsed = parseDatatablePath(p) + const table = parsed.table || 'table_name' + return parsed.schema ? `${parsed.schema}.${table}` : table +} + // Comment prefix per language; mirrors what the parser accepts. function commentPrefix(lang: ScriptLang): string { switch (lang) { @@ -401,31 +443,48 @@ function bodyDuckdb(ctx: TemplateContext): string { if (output) lines.push(`-- Output: ${assetUri(output)}`) lines.push('') + // Resolve the catalog db name to ATTACH. Output's db wins when both sides + // share a kind (the typical pipeline shape: read from one table, write to + // another in the same database). We emit at most one ATTACH per catalog + // kind so the generated script stays readable. + const datatableDb = (() => { + if (output?.kind === 'datatable') return parseDatatablePath(output.path).db + if (input?.kind === 'datatable') return parseDatatablePath(input.path).db + return undefined + })() + const ducklakeDb = (() => { + if (outputKind === 'ducklake' && output?.kind === 'ducklake') + return parseDatatablePath(output.path).db + if (input?.kind === 'ducklake') return parseDatatablePath(input.path).db + return undefined + })() + if (datatableDb) { + lines.push(`ATTACH 'datatable://${datatableDb}' AS pg;`) + } + if (ducklakeDb) { + lines.push(`ATTACH 'ducklake://${ducklakeDb}' AS lake;`) + } + if (datatableDb || ducklakeDb) lines.push('') + const inSql = (() => { if (!input) return null switch (input.kind) { case 's3object': return `read_parquet('s3://${input.path}')` case 'datatable': - // `pg` is the attached Postgres catalog; we schema-qualify - // the table reference so the SELECT doesn't rely on the - // catalog's default schema setting. - return `pg.${qualifiedDatatableRef(input.path)}` - case 'ducklake': { - return null // attach + read shown below - } + // `pg` is the attached Postgres catalog (see ATTACH above). + // Use a 2-part `pg.
` ref so the asset parser maps it + // back to `datatable:///
` — matching the + // `// Upstream` annotation. Schema is only emitted if the + // asset path explicitly includes one (`main/myschema.mytable`). + return `pg.${catalogTableRef(input.path)}` + case 'ducklake': + return `lake.${catalogTableRef(input.path)}` default: return null } })() - if (input?.kind === 'ducklake') { - lines.push( - `-- DuckLake catalog is auto-attached as 'lake' inside windmill duckdb scripts.`, - `-- Reference upstream tables as lake..` - ) - } - switch (outputKind) { case 's3_parquet': if (output) { @@ -451,23 +510,23 @@ function bodyDuckdb(ctx: TemplateContext): string { break case 'ducklake': if (output) { - const tableName = output.path.split('/').slice(1).join('_') || 'table_name' + const ref = `lake.${catalogTableRef(output.path)}` lines.push( - `-- Write into the auto-attached DuckLake catalog.`, - `CREATE TABLE IF NOT EXISTS lake.${tableName} AS`, + `CREATE TABLE IF NOT EXISTS ${ref} AS`, `SELECT * FROM ${inSql ?? '(SELECT 1 AS placeholder)'};` ) } break case 'datatable': if (output) { - // Schema-qualified `pg..
` (rather than - // `pg.
`) so the CREATE TABLE doesn't silently - // land in whatever default schema the attached catalog - // resolves to. - const ref = `pg.${qualifiedDatatableRef(output.path)}` + // 2-part `pg.
` so the asset parser resolves the + // CREATE TABLE write to `datatable:///
` — + // matching the `// Output` annotation. Postgres' + // search_path defaults to `public`, so the DDL still + // lands in the right schema. We only schema-qualify + // when the asset path itself carries an explicit schema. + const ref = `pg.${catalogTableRef(output.path)}` lines.push( - `-- DataTable is exposed as the 'pg' attached database in duckdb scripts.`, `CREATE TABLE IF NOT EXISTS ${ref} AS`, `SELECT * FROM ${inSql ?? '(SELECT 1 AS placeholder)'};` ) diff --git a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte index ce108d2e46..284252c88c 100644 --- a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte @@ -643,6 +643,11 @@ // The most recently dispatched job id — surfaces to AssetRunsPanel so // the new run auto-selects without an extra click. let runsPendingJobId = $state(undefined) + // Counter bumped from the runnable-node action menu to ask the pane to + // open its archive/delete confirmation modal for the loaded script. + // Counter (vs boolean) so successive triggers re-fire even if the user + // dismissed the previous modal without acting. + let requestRemoveSignal = $state(0) // Producers (write/rw edges) for the currently-selected asset, derived // from `graphWithDraft.edges`. Threaded into the details pane so the @@ -913,6 +918,22 @@ }} onAddPipelineScript={(language, scriptPath, source, outputKind) => openMaterializerDraft(language, scriptPath, [source], outputKind)} + onRunnableMenuRemove={(info) => { + // Drafts: drop the local entry immediately — the + // existing onDiscard pathway already handles this + // without a confirm modal. Persisted: select the + // script (so the pane loads it), then bump the + // remove-signal counter so the pane opens its + // archive/delete modal. + if (info.unsaved) { + discardDraft(info.path) + return + } + if (info.runnable_kind !== 'script') return + activeDraftPath = undefined + selection = { kind: 'runnable', runnable_kind: 'script', path: info.path } + requestRemoveSignal++ + }} onRunProducer={async (producer) => { // Saved scripts go through runScriptByPath; drafts // have no DB row yet, so dispatch to runScriptPreview @@ -959,6 +980,7 @@ selectionProducers={activeDraft ? [] : selectionProducers} {runsRefreshKey} {runsPendingJobId} + {requestRemoveSignal} draftScript={activeDraft?.script} {pathPrefix} onDraftPathChange={renameDraft}