This commit is contained in:
Ruben Fiszel
2026-05-07 21:04:10 +00:00
parent 3a2901f6fe
commit 363cd97f9a
6 changed files with 225 additions and 91 deletions
+2 -48
View File
@@ -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",
@@ -70,6 +70,15 @@
path: string
unsaved?: boolean
}) => Promise<string | undefined>
// 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
}
})
}
@@ -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<number | undefined>(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}
</div>
{:else if script}
{#key (script.hash ?? 'draft') + script.language}
{#key (script.hash ?? `draft:${script.path}`) + script.language}
<ScriptEditor
showCaptures={false}
noSyncFromGithub
@@ -1,9 +1,12 @@
<script lang="ts">
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?.()
}
]
: []
)
</script>
<div class="relative">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative" onmouseenter={() => (hover = true)} onmouseleave={() => (hover = false)}>
<!--
Three visual states that compose:
- persisted, non-pipeline: solid 1px gray border, surface-tertiary fill
@@ -78,6 +103,40 @@
</div>
{/if}
</div>
{#if menuItems.length > 0}
<!--
Hover-revealed action menu, top-right of the node. Mirrors the
FlowModuleSchemaItem pattern: position the trigger button just
outside the node frame, only render it on hover (or while the
menu is open) so the canvas isn't visually cluttered when idle.
The pointerdown stop+preventDefault keeps xyflow from selecting
the node when the user is reaching for the menu.
-->
<div class="absolute -top-2 -right-2 h-7 p-1 min-w-7" style="will-change: transform;">
<DropdownV2
items={menuItems}
placement="bottom-end"
bind:open={menuOpen}
fixedHeight={false}
usePointerDownOutside
>
{#snippet buttonReplacement()}
<button
class={twMerge(
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
hover || menuOpen ? 'block' : '!hidden',
'shadow-md rounded-md'
)}
onpointerdown={stopPropagation(preventDefault(() => {}))}
title="Actions"
>
<EllipsisVertical size={12} />
</button>
{/snippet}
</DropdownV2>
</div>
{/if}
</div>
<Handle type="target" position={Position.Top} isConnectable={false} />
@@ -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 `<adj>_<noun>`. 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/<folder>/` for
// s3 / parquet, and uses the more conventional `main/<name>` 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 `<adjective>_<noun>_<slug>` 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/<adj>_<noun>_<slug>` (table refs)
// - s3 parquet / object → `pipelines/<folder>/<adj>_<noun>_<slug>.<ext>`
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 `<schema>.<table>` only when the
// asset path explicitly carries a schema (`main/myschema.mytable`); otherwise
// returns the bare `<table>`. Used inside DuckDB attached-catalog references
// (`pg.<ref>`, `lake.<ref>`) 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.<table>` ref so the asset parser maps it
// back to `datatable://<db>/<table>` — 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.<table_name>.`
)
}
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.<schema>.<table>` (rather than
// `pg.<table>`) 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.<table>` so the asset parser resolves the
// CREATE TABLE write to `datatable://<db>/<table>` —
// 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)'};`
)
@@ -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<string | undefined>(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}