This commit is contained in:
Ruben Fiszel
2026-05-21 06:22:37 +00:00
parent 69dd3cd4a5
commit bdb97aba19
8 changed files with 124 additions and 170 deletions
@@ -236,8 +236,7 @@
pathPrefix,
defaultPathSuffix,
producers: producersByAsset.get(`${a.kind}:${a.path}`) ?? [],
onRunProducer,
onSelectAsset: () => onselect?.({ kind: 'asset', asset_kind: a.kind, path: a.path })
onRunProducer
}
})
}
@@ -279,12 +278,6 @@
: undefined,
downstreamCount: downstreamByScript.get(r.path) ?? 0,
runState,
onSelectSelf: () =>
onselect?.({
kind: 'runnable',
runnable_kind: r.usage_kind,
path: r.path
}),
onRequestRemove: onRunnableMenuRemove
? () =>
onRunnableMenuRemove({
@@ -47,10 +47,6 @@
// cached draft content). Without this callback, the play button
// is hidden — runs only make sense in editor contexts.
onRunProducer?: (producer: AssetProducer) => Promise<string | undefined>
// Forwarded from the canvas. Called when the user runs producers
// from this node so the page can auto-select the asset and open
// the runs panel — matches what clicking the node would do.
onSelectAsset?: () => void
}
// SvelteFlow injects this on the node component when the user clicks
// the node. Combined with our own `hovered` state to drive the
@@ -75,11 +71,6 @@
e.stopPropagation()
if (!$workspaceStore || running || !data.onRunProducer) return
if (scriptProducers.length === 0) return
// Select the asset so the runs panel opens (or refocuses) on this
// node — without this, dispatching a run silently goes off into the
// void with no UI feedback when the panel was closed or pointed at
// a different node.
if (!selected) data.onSelectAsset?.()
running = true
const handler = data.onRunProducer
try {
@@ -0,0 +1,38 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import { AlertTriangle, ArrowRight } from 'lucide-svelte'
interface Props {
open: boolean
onAck: () => void
}
let { open = $bindable(), onAck }: Props = $props()
</script>
<Modal bind:open title="Pipelines is in alpha" kind="X">
<div class="flex flex-col gap-4">
<div class="flex items-start gap-3">
<div
class="shrink-0 flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-800/50"
>
<AlertTriangle size={20} class="text-amber-500 dark:text-amber-400" />
</div>
<div class="flex flex-col gap-2 text-sm text-secondary">
<p>
Pipelines is an <span class="font-semibold text-emphasis">alpha feature preview</span>.
The API, on-disk format, and UI are still evolving and may change without notice.
</p>
<p>
Pipelines built today may need migration as the feature stabilizes. Avoid relying on them
for production workloads until they leave alpha.
</p>
</div>
</div>
<div class="flex items-center justify-end gap-2 pt-2">
<Button variant="accent" unifiedSize="sm" onclick={onAck} endIcon={{ icon: ArrowRight }}>
I understand, continue
</Button>
</div>
</div>
</Modal>
@@ -3,13 +3,11 @@
import { base } from '$lib/base'
import { goto } from '$app/navigation'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Select from '$lib/components/select/Select.svelte'
import FolderPicker from '$lib/components/FolderPicker.svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import { FolderService, OpenAPI } from '$lib/gen'
import { OpenAPI } from '$lib/gen'
import { resource } from 'runed'
import { sendUserToast } from '$lib/utils'
import { ArrowRight, FolderPlus, Loader2 } from 'lucide-svelte'
import { ArrowRight, Loader2 } from 'lucide-svelte'
interface PipelineFolder {
folder: string
@@ -38,53 +36,21 @@
}
)
let allFolders = resource(
() => $workspaceStore,
async (ws) => {
if (!ws) return [] as string[]
return await FolderService.listFolderNames({ workspace: ws })
}
)
let selectedExistingFolder = $state<string | undefined>(undefined)
let newFolderName = $state('')
let creatingFolder = $state(false)
let pickedFolder = $state('')
let visiblePipelines = $derived(
(pipelines.current ?? []).filter((p) => p.folder !== currentFolder)
)
let foldersWithoutPipeline = $derived.by(() => {
const existing = new Set((pipelines.current ?? []).map((p) => p.folder))
return (allFolders.current ?? []).filter((f) => !existing.has(f) && f !== currentFolder)
})
async function openExistingPipeline(folder: string) {
open = false
await goto(`${base}/pipeline/${encodeURIComponent(folder)}`)
}
async function startInExistingFolder() {
if (!selectedExistingFolder) return
await openExistingPipeline(selectedExistingFolder)
}
async function createFolderAndStart() {
const name = newFolderName.trim()
if (!name || !$workspaceStore) return
creatingFolder = true
try {
await FolderService.createFolder({
workspace: $workspaceStore,
requestBody: { name }
})
sendUserToast(`Created folder f/${name}`)
await openExistingPipeline(name)
} catch (e: any) {
sendUserToast(`Failed to create folder: ${e?.body ?? e?.message ?? e}`, true)
} finally {
creatingFolder = false
}
async function openPicked() {
const name = pickedFolder.trim()
if (!name) return
await openExistingPipeline(name)
}
</script>
@@ -124,53 +90,22 @@
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold text-secondary uppercase tracking-wide">
Start in an existing folder
Pick or create a folder
</h3>
<div class="flex items-center gap-2">
<div class="flex-1">
<Select
items={foldersWithoutPipeline.map((f) => ({ label: `f/${f}`, value: f }))}
bind:value={selectedExistingFolder}
placeholder={foldersWithoutPipeline.length === 0
? 'No folders available'
: 'Pick a folder…'}
clearable
/>
<div class="flex-1 min-w-0">
<FolderPicker bind:folderName={pickedFolder} />
</div>
<Button
variant="accent"
unifiedSize="sm"
disabled={!selectedExistingFolder}
onclick={startInExistingFolder}
disabled={!pickedFolder.trim()}
onclick={openPicked}
startIcon={{ icon: ArrowRight }}
>
Open
</Button>
</div>
</section>
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold text-secondary uppercase tracking-wide">
Or create a new folder
</h3>
<div class="flex items-center gap-2">
<div class="flex-1">
<TextInput
bind:value={newFolderName}
placeholder="new-folder-name"
disabled={creatingFolder}
/>
</div>
<Button
variant="accent"
unifiedSize="sm"
disabled={!newFolderName.trim() || creatingFolder}
onclick={createFolderAndStart}
startIcon={{ icon: FolderPlus }}
>
{creatingFolder ? 'Creating…' : 'Create & open'}
</Button>
</div>
</section>
</div>
</Modal>
@@ -52,10 +52,6 @@
// downstream" alternative; the round Play button stays a single-
// click default. Undefined / 0 hides the cascade menu item.
downstreamCount?: number
// 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
@@ -97,9 +93,6 @@
async function runSelf(e: MouseEvent, cascade?: boolean) {
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(cascade != undefined ? { cascade } : undefined)
@@ -51,40 +51,40 @@
<!-- Buttons -->
<div class="flex flex-row gap-2">
<Button
id="create-app-button"
aiId="apps-create-actions-app"
aiDescription="Create a new app"
unifiedSize="lg"
startIcon={{ icon: Plus }}
endIcon={{ icon: LayoutDashboard }}
on:click={openAppTypeModal}
variant="accent"
dropdownItems={[
{
label: 'Import low-code app',
onClick: () => {
appKind = 'lowcode'
importType = 'yaml'
drawer?.toggleDrawer?.()
}
},
{
label: 'Import full-code app',
onClick: () => {
appKind = 'fullcode'
importType = 'yaml'
drawer?.toggleDrawer?.()
}
<Button
id="create-app-button"
aiId="apps-create-actions-app"
aiDescription="Create a new app"
unifiedSize="lg"
startIcon={{ icon: Plus }}
endIcon={{ icon: LayoutDashboard }}
on:click={openAppTypeModal}
variant="accent"
dropdownItems={[
{
label: 'Import low-code app',
onClick: () => {
appKind = 'lowcode'
importType = 'yaml'
drawer?.toggleDrawer?.()
}
]}
>
<div class="flex flex-row items-center"> App </div>
</Button>
</div>
},
{
label: 'Import full-code app',
onClick: () => {
appKind = 'fullcode'
importType = 'yaml'
drawer?.toggleDrawer?.()
}
}
]}
>
<div class="flex flex-row items-center"> App </div>
</Button>
</div>
<!-- App Type Selection Modal -->
<Modal bind:open={appTypeModalOpen} title="Choose your app builder">
<Modal bind:open={appTypeModalOpen} title="Choose your app builder" class="sm:max-w-3xl">
<div class="flex flex-col gap-4 pr-4">
<div class="grid grid-cols-2 gap-8">
<!-- Low-code option -->
@@ -122,8 +122,8 @@
<p class="text-xs text-tertiary mt-1">
Build with React or Svelte with full control and a powerful AI agent.
<br /><br />
Better for complex apps or apps that require full flexibility and control.
<br /><br />
Better for complex apps or apps that require full flexibility and control.
</p>
</div>
</button>
@@ -12,7 +12,7 @@
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import { PythonIcon, TypeScriptIcon } from '$lib/components/common/languageIcons'
import { Code2, Loader2, NetworkIcon, Plus } from 'lucide-svelte'
import { Code2, Loader2, Plus } from 'lucide-svelte'
import YAML from 'yaml'
let drawer: Drawer | undefined = $state(undefined)
@@ -95,6 +95,10 @@
onClick: () => {
wacDrawer?.toggleDrawer?.()
}
},
{
label: 'Pipeline (alpha)',
onClick: () => selectPipeline()
}
]}
>
@@ -103,13 +107,11 @@
</div>
<!-- Flow Type Selection Modal -->
<!-- max-w-4xl widens the modal to fit three tiles comfortably; default
`max-w-lg` (~512px) was sized for two tiles and squeezed the grid.
kind="X" replaces the bottom Cancel button with a top-right close (X)
so the action area is purely the three tile buttons. -->
<Modal bind:open={flowModalOpen} title="Create a new flow" class="sm:max-w-4xl" kind="X">
<!-- kind="X" replaces the bottom Cancel button with a top-right close (X)
so the action area is purely the tile buttons. -->
<Modal bind:open={flowModalOpen} title="Create a new flow" class="sm:max-w-3xl" kind="X">
<div class="flex flex-col gap-4 pr-4">
<div class="grid grid-cols-3 gap-6">
<div class="grid grid-cols-2 gap-6">
<!-- Flow Editor option -->
<button
class="flex flex-col items-center gap-3 p-6 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-all cursor-pointer group"
@@ -183,31 +185,6 @@
</button>
</div>
</div>
<!-- Pipeline option -->
<button
class="relative flex flex-col items-center gap-3 p-6 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-emerald-500 dark:hover:border-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-all cursor-pointer group"
onclick={selectPipeline}
>
<!-- Alpha badge — pipeline is the newest of the three; same
positioning the WAC tile used so it reads consistently. -->
<div
class="absolute top-2 right-2 z-10 px-2 py-0.5 rounded-full bg-emerald-500 text-white text-2xs font-bold uppercase tracking-wide"
>
Alpha
</div>
<div
class="w-32 h-32 rounded-xl bg-emerald-100 dark:bg-emerald-900/40 flex items-center justify-center group-hover:bg-emerald-200 dark:group-hover:bg-emerald-800/50 transition-colors"
>
<NetworkIcon size={32} class="text-emerald-600 dark:text-emerald-400" />
</div>
<div class="text-center">
<h3 class="font-semibold text-primary">Pipeline</h3>
<p class="text-xs text-tertiary mt-1">
Asset-driven DAG of scripts. Trigger on schedule, asset change, or external event.
</p>
</div>
</button>
</div>
</div>
</Modal>
@@ -2,11 +2,37 @@
import { userStore } from '$lib/stores'
import Button from '$lib/components/common/button/Button.svelte'
import PipelinePickerModal from '$lib/components/assets/AssetGraph/PipelinePickerModal.svelte'
import PipelineAlphaAckModal from '$lib/components/assets/AssetGraph/PipelineAlphaAckModal.svelte'
import { ArrowRight, NetworkIcon } from 'lucide-svelte'
import { onMount } from 'svelte'
// Modal is open by default on landing; the editor shell stays empty
// behind it until the user picks or creates a folder.
let pickerOpen = $state(true)
const ACK_STORAGE_KEY = 'pipeline-alpha-ack'
// Gate the picker behind a one-time alpha acknowledgement. We read
// localStorage in onMount (not at module scope) so SSR doesn't blow up.
let ackOpen = $state(false)
let pickerOpen = $state(false)
onMount(() => {
const acked =
typeof localStorage !== 'undefined' && localStorage.getItem(ACK_STORAGE_KEY) === 'true'
if (acked) {
pickerOpen = true
} else {
ackOpen = true
}
})
function handleAck() {
try {
localStorage.setItem(ACK_STORAGE_KEY, 'true')
} catch {
// Storage may be unavailable (private mode, quota); the ack still
// flows through for this visit, the user just sees the modal again next time.
}
ackOpen = false
pickerOpen = true
}
</script>
<svelte:head>
@@ -43,5 +69,6 @@
</div>
</div>
<PipelineAlphaAckModal bind:open={ackOpen} onAck={handleAck} />
<PipelinePickerModal bind:open={pickerOpen} />
{/if}