This commit is contained in:
Ruben Fiszel
2026-06-17 11:41:39 +00:00
parent 4d365a83d6
commit 0f906be920
5 changed files with 127 additions and 10 deletions
@@ -1,6 +1,8 @@
<script lang="ts">
import { JobService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { forLater, getDbClockNow } from '$lib/forLater'
import { displayDate } from '$lib/utils'
let {
jobId,
@@ -17,6 +19,16 @@
let scheduledFor = $state(undefined) as undefined | number
// Bumped when a scheduled-for-later deadline passes so `isScheduledForLater`
// re-evaluates — `forLater` reads the clock but isn't otherwise reactive, so
// a job parked in the future would never flip to "queued" without this tick.
let clockTick = $state(0)
let deadlineTimeout: ReturnType<typeof setTimeout> | undefined
let isScheduledForLater = $derived.by(() => {
clockTick
return scheduledFor != undefined && forLater(scheduledFor)
})
let scheduledForTimeout: number | undefined
$effect(() => {
if (jobId && workspace) {
@@ -41,9 +53,25 @@
}
})
// Flip out of the "scheduled for later" state exactly when the deadline
// passes, so a job whose scheduled time arrives with no free worker then
// correctly surfaces its queue position.
$effect(() => {
// Fetch queue position when loading and we have jobId
if (scheduledFor) {
clearTimeout(deadlineTimeout)
if (scheduledFor != undefined) {
const ms = new Date(scheduledFor).getTime() - 5000 - getDbClockNow().getTime()
if (ms > 0) {
deadlineTimeout = setTimeout(() => clockTick++, ms)
}
}
return () => clearTimeout(deadlineTimeout)
})
$effect(() => {
// Only poll the queue position for jobs actually waiting for a worker —
// a job scheduled for the future isn't "in line", so its position is
// meaningless (and the query counts every job scheduled before it).
if (scheduledFor && !isScheduledForLater) {
// Initial fetch
fetchQueuePosition()
@@ -86,7 +114,11 @@
}
</script>
{#if queueState}
{#if isScheduledForLater && scheduledFor != undefined}
<div class="text-xs ml-4">
<span class="text-blue-600">Scheduled for <b>{displayDate(new Date(scheduledFor))}</b></span>
</div>
{:else if queueState}
<div class="text-xs ml-4">
<span class="text-orange-600">Queue position: <b>{queueState.position}</b></span>
{#if !minimal}
@@ -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}
>
<div class="absolute inset-0 !bg-surface-secondary h-full"></div>
<PanToNode targetId={panToNodeId} {nodes} />
<Controls position="top-right" orientation="horizontal" showLock={false} class="!mr-10" />
<MiniMap
pannable
@@ -0,0 +1,39 @@
<script lang="ts">
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 <SvelteFlow> 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 })
})
})
</script>
+2 -2
View File
@@ -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)
@@ -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<string | undefined>(undefined)
let panToNodeTimer: ReturnType<typeof setTimeout> | 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:<path>`).
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'}
<!-- View mode surfaces activity as a full right pane
@@ -2437,6 +2463,13 @@
: undefined)
if (running && openPath) {
activeRunnable = { kind: 'script', path: openPath }
// Mark the tested runnable as launched-from-here so the
// folder poll's catch-up pulse won't re-flash its edge a
// poll-interval after a fast job already finished (the
// edge is animated zero-latency by `activeRunnable`, and
// the test loader clears that the instant it completes).
// Also upgrades to the fast poll so the badge lands sooner.
activeRunnables.arm(`script:${openPath}`)
// Editor Test path clears via its own callbacks, not
// the job-id effect — drop any stale tracked id so a
// prior canvas run's completion can't clear this hint.