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} +
+ Scheduled for {displayDate(new Date(scheduledFor))} +
+{:else if queueState}
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'}