From 835daea364bfb7ee82bd684f98eea49c808d9d9b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 10 May 2026 11:35:13 +0000 Subject: [PATCH] feat(pipeline): add run button on script nodes + recomputing hint on preview --- .../assets/AssetGraph/AssetGraphCanvas.svelte | 19 +++++ .../AssetGraph/AssetGraphDetailsPane.svelte | 67 +++++++++++++---- .../assets/AssetGraph/RunnableNode.svelte | 71 ++++++++++++++++++- .../(logged)/pipeline/[folder]/+page.svelte | 1 + 4 files changed, 142 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index b14c7001bb..765f2e01ec 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -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({ diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte index 9925786b3c..088999fa64 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte @@ -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//`). 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. --> - {#if selection.asset_kind === 's3object'} - - {:else if selection.asset_kind === 'datatable'} - - {:else} -
- 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. +
+ {#if producerRunning} + +
+ + + Recomputing — preview will refresh when {activeRunnable?.path} finishes + +
+ {/if} +
+ {#if selection.asset_kind === 's3object'} + + {:else if selection.asset_kind === 'datatable'} + + {:else} +
+ 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. +
+ {/if}
- {/if} +
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 + // 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 @@
{/if} + {#if showRun} + +
+ +
+ {/if} {#if menuItems.length > 0}