diff --git a/frontend/src/lib/components/assets/AssetGraph/AddMaterializerScriptModal.svelte b/frontend/src/lib/components/assets/AssetGraph/AddMaterializerScriptModal.svelte
new file mode 100644
index 0000000000..9a92594bd0
--- /dev/null
+++ b/frontend/src/lib/components/assets/AssetGraph/AddMaterializerScriptModal.svelte
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+ {#if triggerAsset}
+
+ The new script will include // on {triggerAsset.prefix}{triggerAsset.path} so it
+ refreshes when this asset changes.
+
+ {:else}
+
+ The new script will include a bare // materialize marker so it's counted as a pipeline
+ member. Any writes the parser detects become its outputs.
+
+ {/if}
+
+
+ {#snippet actions()}
+
+
+ {/snippet}
+
diff --git a/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte
new file mode 100644
index 0000000000..381f878967
--- /dev/null
+++ b/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte
@@ -0,0 +1,155 @@
+
+
+
+ {#snippet trigger()}
+
+ {/snippet}
+
diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte
new file mode 100644
index 0000000000..602d22e592
--- /dev/null
+++ b/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte
@@ -0,0 +1,279 @@
+
+
+
+
+ {
+ if (!e.detail) resetMenuState()
+ }}
+>
+ {#snippet trigger()}
+ {@render triggerSnippet?.()}
+ {/snippet}
+ {#snippet content({ close })}
+ {@const singleKind = kinds.length === 1}
+
+
+ {#if !singleKind}
+
+ {#each kinds as k}
+ {@const isSelected = selectedKindId === k.id}
+
+ {/each}
+
+ {/if}
+
+
+
+ {#if stage === 'lang'}
+
+
Language
+ {#each languages as l}
+
+ {/each}
+
+ {:else if stage === 'path' && selectedLanguage}
+
+
+
+
+
Path
+
+
+ {#if pathPrefix}
+
+ {pathPrefix}
+
+ {/if}
+ handlePathKeydown(e, close)}
+ class="flex-1 min-w-0 px-2 py-1.5 text-sm font-mono bg-transparent focus:outline-none"
+ placeholder="my_materializer"
+ />
+
+
Press Enter to create
+
+
+
+
+
+ {:else if selectedKind?.description}
+
+ {selectedKind.description}
+
+ {/if}
+
+
+ {/snippet}
+
diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelinePickerModal.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelinePickerModal.svelte
new file mode 100644
index 0000000000..9870642c93
--- /dev/null
+++ b/frontend/src/lib/components/assets/AssetGraph/PipelinePickerModal.svelte
@@ -0,0 +1,176 @@
+
+
+
+
+ {#if visiblePipelines.length > 0 || pipelines.loading}
+
+
+ Existing pipelines
+
+ {#if pipelines.loading && !pipelines.current}
+
+
+ Loading…
+
+ {:else if pipelines.error}
+ Failed: {pipelines.error.message}
+ {:else}
+
+ {#each visiblePipelines as p}
+
+ {/each}
+
+ {/if}
+
+ {/if}
+
+
+
+ Start in an existing folder
+
+
+
+
+
+
+
+
+
+
+ Or create a new folder
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/assets/AssetGraph/TriggerNode.svelte b/frontend/src/lib/components/assets/AssetGraph/TriggerNode.svelte
new file mode 100644
index 0000000000..30ab68f82d
--- /dev/null
+++ b/frontend/src/lib/components/assets/AssetGraph/TriggerNode.svelte
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
+
+ {style.label}{data.unsaved ? ' · unsaved' : ''}
+
+ {data.ref}
+
+
+
+
+
diff --git a/frontend/src/lib/components/assets/AssetGraph/parsePipelineAnnotations.ts b/frontend/src/lib/components/assets/AssetGraph/parsePipelineAnnotations.ts
new file mode 100644
index 0000000000..f0f982ebdc
--- /dev/null
+++ b/frontend/src/lib/components/assets/AssetGraph/parsePipelineAnnotations.ts
@@ -0,0 +1,137 @@
+import type { AssetKind } from '$lib/gen'
+import type { NativeTriggerKind } from './types'
+
+// Mirror of backend/parsers/windmill-parser/src/asset_parser.rs
+// `parse_pipeline_annotations` + `parse_trigger_spec`, ported to TS so the
+// pipeline editor can reflect `// materialize` / `// on ` edits live
+// before deploy. Keep the two implementations behaviorally identical — any
+// divergence means the graph preview lies.
+
+const COMMENT_PREFIXES = ['//', '--', '#'] as const
+
+const ASSET_PREFIXES: Array<[string, AssetKind]> = [
+ ['s3://', 's3object'],
+ ['res://', 'resource'],
+ ['$res:', 'resource'],
+ ['ducklake://', 'ducklake'],
+ ['datatable://', 'datatable'],
+ ['volume://', 'volume']
+]
+
+// Non-native, non-schedule trigger keywords — each carries a workspace
+// trigger-path reference (`on `).
+const NATIVE_TRIGGER_KEYWORDS: NativeTriggerKind[] = [
+ 'webhook',
+ 'email',
+ 'kafka',
+ 'mqtt',
+ 'nats',
+ 'postgres',
+ 'sqs',
+ 'gcp'
+]
+
+export type PipelineTriggerAsset = { kind: AssetKind; path: string }
+export type PipelineNativeTrigger = { kind: NativeTriggerKind; path: string }
+
+export type PipelineAnnotations = {
+ isMaterializer: boolean
+ triggerAssets: PipelineTriggerAsset[]
+ schedules: string[] // raw cron expressions, in insertion order
+ nativeTriggers: PipelineNativeTrigger[]
+}
+
+function unquote(s: string): string | undefined {
+ if (s.length >= 2) {
+ const q = s[0]
+ if ((q === '"' || q === "'") && s.endsWith(q)) {
+ return s.slice(1, -1)
+ }
+ }
+ return undefined
+}
+
+function parseAssetSyntax(s: string): PipelineTriggerAsset | undefined {
+ for (const [prefix, kind] of ASSET_PREFIXES) {
+ if (s.startsWith(prefix)) {
+ return { kind, path: s.slice(prefix.length) }
+ }
+ }
+ return undefined
+}
+
+type ParsedSpec =
+ | { kind: 'asset'; value: PipelineTriggerAsset }
+ | { kind: 'schedule'; value: string }
+ | { kind: 'native'; value: PipelineNativeTrigger }
+
+function parseTriggerSpec(s: string): ParsedSpec | undefined {
+ if (s.startsWith('schedule')) {
+ const rest = s.slice('schedule'.length).trimStart()
+ const cron = unquote(rest)
+ if (cron && cron.trim() !== '') return { kind: 'schedule', value: cron }
+ return undefined
+ }
+ for (const kw of NATIVE_TRIGGER_KEYWORDS) {
+ if (s.startsWith(kw)) {
+ const after = s.slice(kw.length)
+ // Require whitespace so `kafkalike` doesn't match `kafka`.
+ if (after.length === 0 || !/\s/.test(after[0])) continue
+ const path = after.trim()
+ if (!path) return undefined
+ return { kind: 'native', value: { kind: kw, path } }
+ }
+ }
+ const asset = parseAssetSyntax(s)
+ if (asset) return { kind: 'asset', value: asset }
+ return undefined
+}
+
+function stripCommentPrefix(line: string): string | undefined {
+ const trimmed = line.trimStart()
+ for (const p of COMMENT_PREFIXES) {
+ if (trimmed.startsWith(p)) return trimmed.slice(p.length)
+ }
+ return undefined
+}
+
+export function parsePipelineAnnotations(code: string): PipelineAnnotations {
+ let isMaterializer = false
+ const triggerAssets: PipelineTriggerAsset[] = []
+ const schedules: string[] = []
+ const nativeTriggers: PipelineNativeTrigger[] = []
+
+ for (const rawLine of code.split('\n')) {
+ const rest = stripCommentPrefix(rawLine)
+ if (rest === undefined) continue
+ const inner = rest.trimStart()
+
+ if (inner.startsWith('materialize')) {
+ const after = inner.slice('materialize'.length)
+ if (after === '' || /\s/.test(after[0])) isMaterializer = true
+ continue
+ }
+
+ if (inner.startsWith('on')) {
+ const after = inner.slice('on'.length)
+ if (after === '' || !/\s/.test(after[0])) continue
+ const specText = after.trim()
+ if (!specText) continue
+ const spec = parseTriggerSpec(specText)
+ if (!spec) continue
+ if (spec.kind === 'asset') {
+ if (!triggerAssets.some((a) => a.kind === spec.value.kind && a.path === spec.value.path)) {
+ triggerAssets.push(spec.value)
+ }
+ } else if (spec.kind === 'schedule') {
+ if (!schedules.includes(spec.value)) schedules.push(spec.value)
+ } else {
+ if (!nativeTriggers.some((n) => n.kind === spec.value.kind && n.path === spec.value.path)) {
+ nativeTriggers.push(spec.value)
+ }
+ }
+ }
+ }
+
+ return { isMaterializer, triggerAssets, schedules, nativeTriggers }
+}
diff --git a/frontend/src/routes/(root)/(logged)/pipeline/+page.js b/frontend/src/routes/(root)/(logged)/pipeline/+page.js
new file mode 100644
index 0000000000..56f4f2a65b
--- /dev/null
+++ b/frontend/src/routes/(root)/(logged)/pipeline/+page.js
@@ -0,0 +1,5 @@
+export function load() {
+ return {
+ stuff: { title: 'Pipelines' }
+ }
+}
diff --git a/frontend/src/routes/(root)/(logged)/pipeline/+page.svelte b/frontend/src/routes/(root)/(logged)/pipeline/+page.svelte
new file mode 100644
index 0000000000..cbc0c88f6d
--- /dev/null
+++ b/frontend/src/routes/(root)/(logged)/pipeline/+page.svelte
@@ -0,0 +1,47 @@
+
+
+
+ Pipeline editor — Windmill
+
+
+{#if $userStore?.operator}
+ Page not available for operators.
+{:else}
+
+
+
+
+
Pipeline editor
+ · no folder selected
+
+
+
+
+
+
+ Pick a folder to open its pipeline.
+
+
+
+
+
+
+{/if}
diff --git a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.js b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.js
new file mode 100644
index 0000000000..f92a72894f
--- /dev/null
+++ b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.js
@@ -0,0 +1,5 @@
+export function load() {
+ return {
+ stuff: { title: 'Pipeline editor' }
+ }
+}
diff --git a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte
new file mode 100644
index 0000000000..aabe5c0c99
--- /dev/null
+++ b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte
@@ -0,0 +1,748 @@
+
+
+
+ Pipeline · {folder} — Windmill
+
+
+{#if $userStore?.operator}
+ Page not available for operators.
+{:else}
+
+
+
+
+
+
Pipeline editor
+ ·
+ {#if otherPipelineFolders.length === 0}
+
+ {:else}
+
+ {#snippet buttonReplacement()}
+
+
+ f/{folder}
+
+
+ {/snippet}
+
+ {/if}
+ {#if summary.length > 0}
+ · {summary.join(' · ')}
+ {/if}
+
+
+
+
+
+
+ {#if graphRes.loading && !graphRes.current}
+
+
+ Loading pipeline…
+
+ {:else if graphRes.error}
+
+ Failed to load pipeline: {graphRes.error.message}
+
+ {:else}
+
+
+ {
+ // Clicking a draft runnable node re-opens it in the pane;
+ // clicking anything else selects it normally and detaches
+ // from any active draft (drafts stay overlaid until saved
+ // or discarded).
+ if (
+ s &&
+ s.kind === 'runnable' &&
+ s.runnable_kind === 'script' &&
+ drafts.has(s.path)
+ ) {
+ activeDraftPath = s.path
+ selection = undefined
+ } else {
+ activeDraftPath = undefined
+ selection = s
+ }
+ }}
+ onAddScriptForAsset={(asset, language, scriptPath) => {
+ const ref = `${ASSET_PREFIX[asset.kind]}${asset.path}`
+ openMaterializerDraft(language, scriptPath, [{ kind: 'asset', ref }])
+ }}
+ onAddMaterializer={(language, scriptPath, source) =>
+ openMaterializerDraft(language, scriptPath, [source])}
+ />
+
+ {#if (selection || activeDraft) && $workspaceStore}
+
+ {
+ liveAnnotations = { scriptPath, annotations }
+ }}
+ onclose={() => {
+ // Close dismisses the pane but preserves drafts so
+ // the user can come back to them. Discarding is
+ // via the explicit "Discard" button in the pane.
+ selection = undefined
+ activeDraftPath = undefined
+ liveAnnotations = {
+ scriptPath: undefined,
+ annotations: {
+ isMaterializer: false,
+ triggerAssets: [],
+ schedules: [],
+ nativeTriggers: []
+ }
+ }
+ }}
+ onDiscard={() => {
+ if (activeDraftPath) discardDraft(activeDraftPath)
+ }}
+ onDraftSaved={async (savedPath) => {
+ discardDraft(savedPath)
+ await graphRes.refetch()
+ }}
+ />
+
+ {/if}
+
+ {/if}
+
+
+
+
+{/if}