diff --git a/frontend/src/lib/components/QueuePosition.svelte b/frontend/src/lib/components/QueuePosition.svelte
index 375942abbc..135feae899 100644
--- a/frontend/src/lib/components/QueuePosition.svelte
+++ b/frontend/src/lib/components/QueuePosition.svelte
@@ -1,6 +1,8 @@
-{#if queueState}
+{#if isScheduledForLater && scheduledFor != undefined}
+
Queue position: {queueState.position}
{#if !minimal}
diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte
index e86e126d64..c9cc92f3b8 100644
--- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte
+++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte
@@ -14,6 +14,7 @@
import TriggerNode, { type TriggerNodeKind } from './TriggerNode.svelte'
import AddNode from './AddNode.svelte'
import AssetGraphEdge from './AssetGraphEdge.svelte'
+ import PanToNode from './PanToNode.svelte'
import { layoutAssetGraph } from './assetGraphLayout'
import { buildDownstreamMap } from './graphTraversal'
import type { AssetGraphResponse, AssetGraphSelection, NativeTriggerKind } from './types'
@@ -132,6 +133,12 @@
// Script paths of the expanded (pinned) run(s) — a soft blue ring that
// persists until collapsed.
selectedRunPaths?: string[]
+ // Graph-id (`${kind}:${path}`) of a node to smoothly pan into the
+ // center of the viewport — set by the page when a new draft node is
+ // created so the user's eye follows it. Opt-in: only the pipeline
+ // editor passes it, so the asset-graph page is unaffected. The page
+ // clears it once the pan has had time to settle.
+ panToNodeId?: string | undefined
}
let {
graph,
@@ -152,7 +159,8 @@
onOpenWebhook,
onOpenDataUpload,
hoveredPaths,
- selectedRunPaths
+ selectedRunPaths,
+ panToNodeId
}: Props = $props()
// `${kind}:${path}` ids for the hovered / pinned runs (both script and flow
@@ -560,7 +568,11 @@
// class applies — a node is either a runnable or an asset).
const assetEmph = assetEmphasis.get(n.id)
const assetClass =
- assetEmph === 'output' ? 'wm-asset-output' : assetEmph === 'input' ? 'wm-asset-input' : undefined
+ assetEmph === 'output'
+ ? 'wm-asset-output'
+ : assetEmph === 'input'
+ ? 'wm-asset-input'
+ : undefined
return {
id: n.id,
type: n.type,
@@ -810,6 +822,7 @@
--background-color={false}
>
+
+ import { useSvelteFlow, type Node } from '@xyflow/svelte'
+ import { untrack } from 'svelte'
+ import { NODE } from '$lib/components/graph/util'
+
+ // Smoothly pans the viewport so a freshly-created node lands in the middle,
+ // keeping the current zoom. Lives inside so useSvelteFlow() has
+ // the flow context (same pattern as ViewportResizer). The parent owns the
+ // session lifetime — it clears `targetId` once the layout has settled.
+ let { targetId, nodes }: { targetId: string | undefined; nodes: Node[] } = $props()
+
+ const { setCenter, getViewport } = useSvelteFlow()
+
+ // Re-centering across reactive settles is intentional: opening the details
+ // pane resizes the canvas, which shifts every node's x a tick later. The
+ // `lastKey` guard re-fires setCenter only when the target's own center
+ // actually moves, so unrelated node updates (activity polls) don't restart
+ // the animation.
+ let lastKey = ''
+ $effect(() => {
+ const id = targetId
+ const ns = nodes
+ if (!id) {
+ lastKey = ''
+ return
+ }
+ untrack(() => {
+ const node = ns.find((n) => n.id === id)
+ if (!node) return // not laid out yet — re-runs when `nodes` updates
+ const cx = node.position.x + NODE.width / 2
+ const cy = node.position.y + NODE.height / 2
+ const key = `${id}:${cx}:${cy}`
+ if (key === lastKey) return
+ lastKey = key
+ const { zoom } = getViewport()
+ void setCenter(cx, cy, { zoom, duration: 400 })
+ })
+ })
+
diff --git a/frontend/src/lib/forLater.ts b/frontend/src/lib/forLater.ts
index b3ce8001e2..281cbe1e92 100644
--- a/frontend/src/lib/forLater.ts
+++ b/frontend/src/lib/forLater.ts
@@ -21,8 +21,8 @@ export async function computeDrift() {
}
}
-export function forLater(scheduledString: string): boolean {
- return getDbClockNow() < subtractSeconds(new Date(scheduledString), 5)
+export function forLater(scheduled: string | number | Date): boolean {
+ return getDbClockNow() < subtractSeconds(new Date(scheduled), 5)
}
const limit = pLimit(1)
diff --git a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte
index 3b83e4cadb..ef133de83d 100644
--- a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte
@@ -324,7 +324,8 @@
if (state && (Array.isArray(state.drafts) || typeof state.activeDraftPath === 'string')) {
return {
drafts: Array.isArray(state.drafts) ? state.drafts : [],
- activeDraftPath: typeof state.activeDraftPath === 'string' ? state.activeDraftPath : undefined
+ activeDraftPath:
+ typeof state.activeDraftPath === 'string' ? state.activeDraftPath : undefined
}
}
} catch (e) {
@@ -441,7 +442,11 @@
try {
if (typeof localStorage !== 'undefined') {
if (isEmpty) localStorage.removeItem(key)
- else localStorage.setItem(key, encodeState({ drafts: serialized, activeDraftPath: activePath }))
+ else
+ localStorage.setItem(
+ key,
+ encodeState({ drafts: serialized, activeDraftPath: activePath })
+ )
}
} catch (e) {
console.warn('failed to mirror pipeline state', e)
@@ -612,6 +617,21 @@
} as unknown as Script
}
+ // Graph-id of the node the canvas should smoothly pan to. Set when a new
+ // draft node is created; cleared on a timer once the relayout (the details
+ // pane opening resizes the canvas, shifting every node) has settled so the
+ // pan lands on the final position rather than a pre-resize one.
+ let panToNodeId = $state(undefined)
+ let panToNodeTimer: ReturnType | undefined = undefined
+ function focusPipelineNode(id: string) {
+ panToNodeId = id
+ if (panToNodeTimer) clearTimeout(panToNodeTimer)
+ panToNodeTimer = setTimeout(() => {
+ if (panToNodeId === id) panToNodeId = undefined
+ panToNodeTimer = undefined
+ }, 600)
+ }
+
function openMaterializerDraft(
language: ScriptLang,
scriptPath: string,
@@ -632,6 +652,10 @@
activeDraftPath = scriptPath
selection = undefined
+ // Follow the new node with a smooth pan. The id matches the runnable
+ // node the canvas builds for a draft script (`script:`).
+ focusPipelineNode(`script:${scriptPath}`)
+
// User filled the optional prompt on the path stage — fire off a
// chat request so the AI bootstraps the body. The seeded template
// (already in `script.content`) acts as scaffolding the AI
@@ -797,7 +821,8 @@
let assets: AssetWithAltAccessType[] = []
try {
const inferred = await inferAssets(script.language, script.content)
- if (inferred?.status !== 'error') assets = (inferred?.assets ?? []) as AssetWithAltAccessType[]
+ if (inferred?.status !== 'error')
+ assets = (inferred?.assets ?? []) as AssetWithAltAccessType[]
} catch {
// Same fallback as above — an unparsable body deploys with no
// lineage rather than the stale snapshot.
@@ -2354,6 +2379,7 @@
onAddPipelineScript={mode === 'edit' ? handleAddPipelineScript : undefined}
onRunnableMenuRemove={mode === 'edit' ? handleRunnableMenuRemove : undefined}
onRunProducer={mode === 'edit' ? handleRunProducer : undefined}
+ {panToNodeId}
/>
{#if mode === 'edit'}