feat(pipeline): add run button on script nodes + recomputing hint on preview

This commit is contained in:
Ruben Fiszel
2026-05-10 11:35:13 +00:00
parent e135cc5495
commit 835daea364
4 changed files with 142 additions and 16 deletions
@@ -195,6 +195,25 @@
partition_kind: r.partition_kind,
freshness: r.freshness,
unsaved: r.unsaved ?? false,
// Same dispatch the asset node uses, only routed when the
// runnable is a script (the page handler short-circuits
// flows). The run button mirrors the asset-node affordance:
// click → run with no extra UI clutter.
onRunSelf:
r.usage_kind === 'script' && onRunProducer
? () =>
onRunProducer({
kind: 'script',
path: r.path,
unsaved: r.unsaved ?? false
})
: undefined,
onSelectSelf: () =>
onselect?.({
kind: 'runnable',
runnable_kind: r.usage_kind,
path: r.path
}),
onRequestRemove: onRunnableMenuRemove
? () =>
onRunnableMenuRemove({
@@ -105,6 +105,11 @@
// `activeRunnable` overlay (which animates edges around the
// running script) once execution finishes.
onRunCompleted?: () => void
// Runnable currently executing — shared with the canvas. When it
// matches one of the selected asset's producers, the preview
// gets a "Recomputing…" banner so the user knows the rendered
// snapshot is about to be replaced.
activeRunnable?: { kind: 'script' | 'flow'; path: string } | undefined
// Folder-scoped non-editable prefix shown next to the suffix
// editor when the user renames a draft (e.g. `f/<folder>/`). The
// new path = pathPrefix + suffix.
@@ -138,11 +143,23 @@
runsRefreshKey,
runsPendingJobId,
onRunCompleted,
activeRunnable,
pathPrefix = '',
onDraftPathChange,
requestRemoveSignal
}: Props = $props()
// True when the script that writes to the currently-selected asset is
// running right now. Drives the "Recomputing…" banner above the
// preview and pairs with `onRunCompleted` (which bumps
// `previewRefreshKey`) to reload the preview when the run finishes.
let producerRunning = $derived(
!!activeRunnable &&
selectionProducers.some(
(p) => p.kind === activeRunnable!.kind && p.path === activeRunnable!.path
)
)
// Bound from ScriptEditor — populated by inferAssets on every code
// change. Forwarded to the page so the canvas can re-derive write
// edges as the user edits the body (e.g. renaming a CREATE TABLE
@@ -588,21 +605,43 @@
not by asset kind, so it's useful for every asset. -->
<Splitpanes horizontal class="!h-full">
<Pane size={55} minSize={20}>
{#if selection.asset_kind === 's3object'}
<S3FilePreview
fileKey={selection.path}
showMetadata
class="h-full"
refreshKey={previewRefreshKey}
/>
{:else if selection.asset_kind === 'datatable'}
<DataTablePreview path={selection.path} class="h-full" refreshKey={previewRefreshKey} />
{:else}
<div class="p-3 text-xs text-secondary">
No inline preview yet for {selection.asset_kind}. Use the producer/consumer arrows in
the graph to navigate. Runs of the upstream script are below.
<div class="flex flex-col h-full">
{#if producerRunning}
<!-- The asset's producing script is executing right now;
the snapshot below is stale until the run finishes,
at which point onRunCompleted bumps previewRefreshKey
and the preview component refetches automatically. -->
<div
class="shrink-0 flex items-center gap-2 px-3 py-1.5 text-2xs bg-blue-50 dark:bg-blue-950/40 border-b border-blue-200 dark:border-blue-900/60 text-blue-700 dark:text-blue-300"
>
<Loader2 size={12} class="animate-spin shrink-0" />
<span class="truncate">
Recomputing — preview will refresh when {activeRunnable?.path} finishes
</span>
</div>
{/if}
<div class="flex-1 min-h-0 relative">
{#if selection.asset_kind === 's3object'}
<S3FilePreview
fileKey={selection.path}
showMetadata
class="h-full"
refreshKey={previewRefreshKey}
/>
{:else if selection.asset_kind === 'datatable'}
<DataTablePreview
path={selection.path}
class="h-full"
refreshKey={previewRefreshKey}
/>
{:else}
<div class="p-3 text-xs text-secondary">
No inline preview yet for {selection.asset_kind}. Use the producer/consumer arrows
in the graph to navigate. Runs of the upstream script are below.
</div>
{/if}
</div>
{/if}
</div>
</Pane>
<Pane size={45} minSize={20}>
<AssetRunsPanel
@@ -1,12 +1,23 @@
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import { Code2, EllipsisVertical, GitBranch, Layers, Timer, Trash2 } from 'lucide-svelte'
import {
Code2,
EllipsisVertical,
GitBranch,
Layers,
Loader2,
Play,
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'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
interface Props {
data: {
@@ -18,14 +29,28 @@
// True for nodes synthesized from local drafts (script not yet
// persisted). Same convention as `unsaved` on triggers/edges.
unsaved?: boolean
// Page-supplied dispatch that runs THIS node (saved → runScriptByPath,
// unsaved → runScriptPreview with the locally-cached draft content).
// Wired only for script runnables — flows are ignored upstream. When
// undefined, the play button is hidden — matches the asset-node
// behaviour outside editor contexts.
onRunSelf?: () => Promise<string | undefined>
// Called before running so the details pane focuses this script —
// mirrors AssetNode.onSelectAsset, keeps the runs/output in view
// instead of dispatching into nowhere.
onSelectSelf?: () => void
// 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
}
// SvelteFlow injects this when the user clicks the node. Combined with
// hover state to drive the run-button visibility (same pattern as
// AssetNode).
selected?: boolean
}
let { data }: Props = $props()
let { data, selected = false }: Props = $props()
// Icon + emerald accent already convey "pipeline script" vs "flow"; the
// uppercase kind label was visually noisy and redundant. Tooltip on hover
@@ -41,6 +66,27 @@
let hover = $state(false)
let menuOpen = $state(false)
let running = $state(false)
let canRun = $derived(data.runnable_kind === 'script' && data.onRunSelf != undefined)
// Reveal pattern matches AssetNode: visible while hovering, selected, or
// already running (so the loader doesn't disappear under the cursor).
let showRun = $derived(canRun && (hover || selected || running))
async function runSelf(e: MouseEvent) {
e.stopPropagation()
if (!$workspaceStore || running || !data.onRunSelf) return
// Focus this runnable so the details pane opens to its editor — same
// rationale as AssetNode.onSelectAsset before runProducers.
if (!selected) data.onSelectSelf?.()
running = true
try {
await data.onRunSelf()
} catch (err: any) {
sendUserToast(`Failed to run: ${err.body ?? err.message}`, true)
} finally {
running = false
}
}
let menuItems: Item[] = $derived(
data.onRequestRemove
@@ -103,6 +149,27 @@
</div>
{/if}
</div>
{#if showRun}
<!-- Run button revealed on hover/select. Matches the placement, size,
and behaviour of AssetNode's run button so both nodes feel
consistent. Drafts are runnable too (the page handler routes to
runScriptPreview), so no greyed-out state. -->
<div class="absolute -left-3 top-1/2 -translate-y-1/2 z-10">
<button
type="button"
onclick={runSelf}
disabled={running}
class="bg-blue-500 hover:bg-blue-600 disabled:opacity-60 text-white rounded-full w-6 h-6 grid place-items-center shadow border-2 border-surface-secondary leading-none"
title={`Run ${data.path}${data.unsaved ? ' (draft, runs as preview)' : ''}`}
>
{#if running}
<Loader2 size={14} class="animate-spin" />
{:else}
<Play size={14} strokeWidth={2.5} class="translate-x-px" />
{/if}
</button>
</div>
{/if}
{#if menuItems.length > 0}
<!--
@@ -1148,6 +1148,7 @@
selectionProducers={activeDraft ? [] : selectionProducers}
{runsRefreshKey}
{runsPendingJobId}
{activeRunnable}
onRunCompleted={() => (activeRunnable = undefined)}
{requestRemoveSignal}
draftScript={activeDraft?.script}