diff --git a/frontend/src/app/BootGate.jsx b/frontend/src/app/BootGate.jsx
new file mode 100644
index 00000000..e4f653e2
--- /dev/null
+++ b/frontend/src/app/BootGate.jsx
@@ -0,0 +1,49 @@
+'use client';
+
+// InGen Studio — boot / persistence guard
+//
+// Runs once per full page load, on the client, before any child effect reads the store. Saved
+// pipelines persist ACROSS reloads (autosave writes them to localStorage); this gate's only job is
+// a one-time migration: if the stored data version differs from the code's DATA_VERSION, clear our
+// namespace once so we never try to load models written against an incompatible schema.
+
+import { STORAGE_NAMESPACE, DATA_VERSION } from '../models/constants.js';
+
+const NS = STORAGE_NAMESPACE;
+const VERSION_KEY = `${NS}:version`;
+
+let booted = false;
+
+function migrateIfStale() {
+ if (localStorage.getItem(VERSION_KEY) === DATA_VERSION) return; // up to date — keep saved work
+
+ const oldVersion = localStorage.getItem(VERSION_KEY) ?? 'none';
+ const staleKeys = Object.keys(localStorage).filter((k) => k.startsWith(NS + ':'));
+
+ // Snapshot old data into a backup key before wiping so a future recovery path is possible.
+ if (staleKeys.length > 0) {
+ const backup = {};
+ staleKeys.forEach((k) => { backup[k] = localStorage.getItem(k); });
+ try {
+ localStorage.setItem(`${NS}:backup:${oldVersion}`, JSON.stringify(backup));
+ } catch {
+ // Storage full — skip backup silently, still need to migrate.
+ }
+ console.warn(
+ `[InGen] Data schema changed (${oldVersion} → ${DATA_VERSION}). ` +
+ `${staleKeys.length} key(s) cleared. Backup saved to "${NS}:backup:${oldVersion}".`
+ );
+ }
+
+ staleKeys.forEach((k) => localStorage.removeItem(k));
+ localStorage.setItem(VERSION_KEY, DATA_VERSION);
+}
+
+export default function BootGate({ children }) {
+ // localStorage is client-only; guard so this is inert during server rendering.
+ if (typeof window !== 'undefined' && !booted) {
+ booted = true;
+ migrateIfStale();
+ }
+ return children;
+}
diff --git a/frontend/src/app/configs/[configId]/history/page.jsx b/frontend/src/app/configs/[configId]/history/page.jsx
new file mode 100644
index 00000000..220a6804
--- /dev/null
+++ b/frontend/src/app/configs/[configId]/history/page.jsx
@@ -0,0 +1,7 @@
+'use client';
+
+import HistoryView from '../../../../components/run/HistoryView.jsx';
+
+export default function HistoryPage() {
+ return ;
+}
diff --git a/frontend/src/app/configs/[configId]/interfaces/[interfaceName]/page.jsx b/frontend/src/app/configs/[configId]/interfaces/[interfaceName]/page.jsx
new file mode 100644
index 00000000..9b1a9d57
--- /dev/null
+++ b/frontend/src/app/configs/[configId]/interfaces/[interfaceName]/page.jsx
@@ -0,0 +1,7 @@
+'use client';
+
+import InterfaceEditor from '../../../../../components/editor/InterfaceEditor.jsx';
+
+export default function InterfaceEditorPage() {
+ return ;
+}
diff --git a/frontend/src/app/configs/[configId]/layout.jsx b/frontend/src/app/configs/[configId]/layout.jsx
new file mode 100644
index 00000000..465651f7
--- /dev/null
+++ b/frontend/src/app/configs/[configId]/layout.jsx
@@ -0,0 +1,25 @@
+'use client';
+
+// InGen Studio — config workspace layout (segment: /configs/[configId])
+//
+// Composes the providers (catalog + the config document) around the WorkspaceLayout. Every nested
+// route renders into WorkspaceLayout's content slot and shares this config context. This is the
+// Next App Router equivalent of the old react-router element.
+
+import { useParams } from 'next/navigation';
+
+import { CatalogProvider } from '../../../state/CatalogContext.jsx';
+import { ConfigProvider } from '../../../state/ConfigContext.jsx';
+import WorkspaceLayout from '../../../components/layout/WorkspaceLayout.jsx';
+
+export default function ConfigLayout({ children }) {
+ const { configId } = useParams();
+ return (
+
+ {/* key={configId} remounts the document store on config switch — clean load state per id. */}
+
+ {children}
+
+
+ );
+}
diff --git a/frontend/src/app/configs/[configId]/page.jsx b/frontend/src/app/configs/[configId]/page.jsx
new file mode 100644
index 00000000..003e314a
--- /dev/null
+++ b/frontend/src/app/configs/[configId]/page.jsx
@@ -0,0 +1,28 @@
+'use client';
+
+// Workspace index. New flow: if the first interface has no sources yet, show the source-first
+// Start screen (pick a source → choose inFlow/inChat). Once it has a source, redirect into the
+// interface editor so reloads land where you left off.
+
+import { useEffect } from 'react';
+import { useParams, useRouter } from 'next/navigation';
+
+import { useConfig } from '../../../state/ConfigContext.jsx';
+import Start from '../../../components/start/Start.jsx';
+
+export default function ConfigIndex() {
+ const { configId } = useParams();
+ const router = useRouter();
+ const { model, status } = useConfig();
+
+ const first = model?.interfaceOrder?.[0];
+ const firstHasSources = first && (model.interfacesByName[first]?.sources?.length ?? 0) > 0;
+
+ useEffect(() => {
+ if (firstHasSources) router.replace(`/configs/${configId}/interfaces/${first}`);
+ }, [configId, first, firstHasSources, router]);
+
+ if (status === 'loading' || !model) return
Loading config…
;
+ if (firstHasSources) return
Opening editor…
;
+ return ;
+}
diff --git a/frontend/src/app/configs/[configId]/run/page.jsx b/frontend/src/app/configs/[configId]/run/page.jsx
new file mode 100644
index 00000000..76a07f59
--- /dev/null
+++ b/frontend/src/app/configs/[configId]/run/page.jsx
@@ -0,0 +1,7 @@
+'use client';
+
+import RunConsole from '../../../../components/run/RunConsole.jsx';
+
+export default function RunPage() {
+ return ;
+}
diff --git a/frontend/src/app/layout.jsx b/frontend/src/app/layout.jsx
new file mode 100644
index 00000000..e4c6e86a
--- /dev/null
+++ b/frontend/src/app/layout.jsx
@@ -0,0 +1,32 @@
+// InGen Studio — root layout
+//
+// Server component that owns the document shell. Global styles (which also pull in the Inter
+// webfont via @import) are imported here. BootGate seeds localStorage on the client; AppShell is
+// the persistent brand-bar frame that used to be the top-level react-router layout route.
+
+import '../index.css';
+import BootGate from './BootGate.jsx';
+import AppShell from '../components/layout/AppShell.jsx';
+
+export const metadata = {
+ title: 'InGen Data Transformation',
+ description: 'YAML interface authoring for the InGen data transformation pipeline.',
+ icons: { icon: '/favicon.svg' },
+};
+
+export const viewport = {
+ width: 'device-width',
+ initialScale: 1,
+};
+
+export default function RootLayout({ children }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/frontend/src/app/not-found.jsx b/frontend/src/app/not-found.jsx
new file mode 100644
index 00000000..9df6af9f
--- /dev/null
+++ b/frontend/src/app/not-found.jsx
@@ -0,0 +1,10 @@
+import Link from 'next/link';
+
+export default function NotFound() {
+ return (
+
+
Page not found.
+ Back to editor
+
+ );
+}
diff --git a/frontend/src/app/page.jsx b/frontend/src/app/page.jsx
new file mode 100644
index 00000000..edac3f92
--- /dev/null
+++ b/frontend/src/app/page.jsx
@@ -0,0 +1,275 @@
+'use client';
+
+// Index route — the pipelines ledger. Lists every saved pipeline (localStorage, via ConfigService)
+// so you can resume past work or start a new one. Replaces the old straight-to-draft redirect.
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { Plus, Trash2, Layers, Pencil, Check, X, Copy, Upload } from 'lucide-react';
+
+import { getServices } from '../services/index.js';
+import { makeId } from '../utils/id.js';
+import {
+ createEmptyConfig,
+ createEmptyInterface,
+ upsertInterface,
+} from '../models/configModel.js';
+import { yamlToModel } from '../serializers/index.js';
+import ConfirmDialog from '../components/common/ConfirmDialog.jsx';
+import { useDocTitle } from '../hooks/useDocTitle.js';
+
+const fmtDate = (iso) => {
+ if (!iso) return '—';
+ const d = new Date(iso);
+ // Number.isNaN does not coerce, so it must be given the timestamp — Number.isNaN(dateObject)
+ // is always false and would let an invalid date render as "Invalid Date".
+ return Number.isNaN(d.getTime())
+ ? '—'
+ : d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
+};
+
+const errMessage = (err, fallback) => err?.message || fallback;
+
+export default function Home() {
+ const router = useRouter();
+ const [items, setItems] = useState(null); // null = loading, [] = empty
+ const [busy, setBusy] = useState(false);
+ const [confirmDelete, setConfirmDelete] = useState(null); // { id, name } | null
+ const [editingId, setEditingId] = useState(null); // id of item being renamed
+ const [editName, setEditName] = useState('');
+ const [error, setError] = useState(null); // { label, message } | null
+ const fileInputRef = useRef(null);
+
+ useDocTitle('Your pipelines');
+ const refresh = useCallback(() => {
+ getServices().config.list()
+ .then(setItems)
+ .catch((err) => {
+ setItems([]);
+ setError({ label: 'Could not load pipelines', message: errMessage(err, 'Storage unavailable') });
+ });
+ }, []);
+ useEffect(() => { refresh(); }, [refresh]);
+
+ const createPipeline = () => {
+ if (busy) return;
+ setBusy(true);
+ const id = makeId('cfg');
+ let model = createEmptyConfig({ id, name: 'Untitled pipeline' });
+ model = upsertInterface(model, 'interface_1', createEmptyInterface());
+ // Navigate optimistically, persist in the background.
+ getServices().config.create(model).catch(() => {}).finally(() => setBusy(false));
+ router.push(`/configs/${id}`);
+ };
+
+ const requestDelete = (e, id, name) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setConfirmDelete({ id, name });
+ };
+
+ const confirmDeletePipeline = async () => {
+ if (!confirmDelete) return;
+ try {
+ await getServices().config.remove(confirmDelete.id);
+ } catch (err) {
+ setError({ label: 'Delete failed', message: errMessage(err, 'Could not delete this pipeline') });
+ } finally {
+ setConfirmDelete(null);
+ refresh();
+ }
+ };
+
+ const startRename = (e, id, name) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setEditingId(id);
+ setEditName(name || '');
+ };
+
+ const commitRename = async () => {
+ const name = editName.trim();
+ if (!name || !editingId) { setEditingId(null); return; }
+ try {
+ // svc.get throws (rather than returning null) when the id is gone — e.g. the pipeline was
+ // deleted in another tab while the rename box was open.
+ const svc = getServices().config;
+ const m = await svc.get(editingId);
+ await svc.update({ ...m, meta: { ...m.meta, name } });
+ } catch (err) {
+ setError({ label: 'Rename failed', message: errMessage(err, 'Could not rename this pipeline') });
+ } finally {
+ setEditingId(null);
+ refresh();
+ }
+ };
+
+ const cancelRename = () => setEditingId(null);
+
+ // ── Duplicate pipeline ────────────────────────────────────────────────────
+ const duplicatePipeline = async (e, id, name) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (busy) return;
+ setBusy(true);
+ try {
+ const svc = getServices().config;
+ const original = await svc.get(id); // throws if the id no longer exists
+ const newId = makeId('cfg');
+ const copy = {
+ ...original,
+ meta: { ...original.meta, id: newId, name: `${name || 'Untitled'} (copy)` },
+ };
+ await svc.create(copy);
+ refresh();
+ } catch (err) {
+ setError({ label: 'Duplicate failed', message: errMessage(err, 'Could not duplicate this pipeline') });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ // ── Import YAML ───────────────────────────────────────────────────────────
+ const triggerImport = () => {
+ setError(null);
+ fileInputRef.current?.click();
+ };
+
+ const handleImportFile = async (e) => {
+ const file = e.target.files?.[0];
+ if (!fileInputRef.current) return;
+ fileInputRef.current.value = '';
+ if (!file) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const text = await file.text();
+ const newId = makeId('cfg');
+ const parsed = yamlToModel(text, { id: newId, name: file.name.replace(/\.ya?ml$/i, '') });
+ await getServices().config.create(parsed);
+ router.push(`/configs/${newId}`);
+ } catch (err) {
+ setError({ label: 'Import failed', message: errMessage(err, 'Failed to parse YAML') });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ ⌗ InGen Studio
+
Your pipelines
+
Pick up where you left off, or start a new one. Everything is saved locally in this browser.
+ );
+}
diff --git a/frontend/src/components/common/ConfirmDialog.jsx b/frontend/src/components/common/ConfirmDialog.jsx
new file mode 100644
index 00000000..dc6d3591
--- /dev/null
+++ b/frontend/src/components/common/ConfirmDialog.jsx
@@ -0,0 +1,61 @@
+// InGen Studio — ConfirmDialog
+//
+// Drop-in replacement for window.confirm(). Renders a modal overlay with a title, message,
+// and Cancel / Confirm buttons. Confirm button can be styled as danger (red) or default.
+
+import { useEffect, useRef } from 'react';
+
+/**
+ * @param {{
+ * open: boolean,
+ * title: string,
+ * message: string,
+ * confirmLabel?: string,
+ * danger?: boolean,
+ * onConfirm: () => void,
+ * onCancel: () => void,
+ * }} props
+ */
+export default function ConfirmDialog({ open, title, message, confirmLabel = 'Confirm', danger = false, onConfirm, onCancel }) {
+ const cancelRef = useRef(null);
+
+ // Focus the cancel button when dialog opens (safe default).
+ useEffect(() => {
+ if (open) cancelRef.current?.focus();
+ }, [open]);
+
+ // Close on Escape key.
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e) => { if (e.key === 'Escape') onCancel(); };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [open, onCancel]);
+
+ if (!open) return null;
+
+ return (
+
+
e.stopPropagation()}
+ >
+
{title}
+
{message}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/common/ErrorBoundary.jsx b/frontend/src/components/common/ErrorBoundary.jsx
new file mode 100644
index 00000000..b57ca801
--- /dev/null
+++ b/frontend/src/components/common/ErrorBoundary.jsx
@@ -0,0 +1,43 @@
+// InGen Studio — ErrorBoundary
+//
+// Catches render/lifecycle exceptions anywhere in the tree below it so a single bad record or a
+// thrown component never blanks the whole SPA. React only supports class components as error
+// boundaries (no hook equivalent), hence the class. Offers a reset that re-renders the children and
+// a link back to the configs list.
+
+import { Component } from 'react';
+import Link from 'next/link';
+
+export default class ErrorBoundary extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { error: null };
+ }
+
+ static getDerivedStateFromError(error) {
+ return { error };
+ }
+
+ componentDidCatch(error, info) {
+ // Keep a breadcrumb in the console for debugging; no external logging in scope.
+ console.error('Unhandled UI error:', error, info?.componentStack);
+ }
+
+ reset = () => this.setState({ error: null });
+
+ render() {
+ if (this.state.error) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/common/SourceActionDialog.jsx b/frontend/src/components/common/SourceActionDialog.jsx
new file mode 100644
index 00000000..9cdd09bb
--- /dev/null
+++ b/frontend/src/components/common/SourceActionDialog.jsx
@@ -0,0 +1,78 @@
+// SourceActionDialog — shown when the user connects/adds a source to an interface
+// that already has a base source. Offers two choices:
+// A) Create a new interface (independent pipeline with its own columns/output)
+// B) Merge into the current pipeline (auto-create a merge/union transform)
+
+import { useEffect, useRef } from 'react';
+import { GitMerge, Layers } from 'lucide-react';
+
+/**
+ * @param {{
+ * open: boolean,
+ * sourceId: string,
+ * onNewInterface: () => void,
+ * onMerge: () => void,
+ * onCancel: () => void,
+ * }} props
+ */
+export default function SourceActionDialog({ open, sourceId, onNewInterface, onMerge, onCancel }) {
+ const cancelRef = useRef(null);
+
+ useEffect(() => {
+ if (open) cancelRef.current?.focus();
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e) => { if (e.key === 'Escape') onCancel(); };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [open, onCancel]);
+
+ if (!open) return null;
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+ How should "{sourceId}" connect?
+
+
+ This interface already has a base source. Choose how to wire the new source:
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/layout/AppShell.jsx b/frontend/src/components/layout/AppShell.jsx
new file mode 100644
index 00000000..8008a5b8
--- /dev/null
+++ b/frontend/src/components/layout/AppShell.jsx
@@ -0,0 +1,121 @@
+'use client';
+
+// InGen Studio — AppShell
+//
+// Outermost frame: brand bar with the Graph/Chat view switcher at center, config name +
+// status on the right. The view mode is shared via ViewModeContext so the editor and sidebar
+// can read it. Workspace-specific controls (YAML toggle, Run) are also here.
+//
+// Under Next this is the persistent client frame the root layout wraps around every route; the
+// routed page renders into {children} where the old react-router used to be.
+
+import { useSyncExternalStore } from 'react';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
+import ErrorBoundary from '../common/ErrorBoundary.jsx';
+import { ViewModeProvider, useViewMode } from '../../state/ViewModeContext.jsx';
+import { ChatSessionProvider } from '../../state/ChatSessionContext.jsx';
+import { GraphSelectionProvider } from '../../state/GraphSelectionContext.jsx';
+import { ChevronLeft, GitBranch, MessageSquare } from 'lucide-react';
+import { parentPath } from './navPaths.js';
+
+function BrandBar() {
+ const { viewMode, setViewMode } = useViewMode();
+ const pathname = usePathname();
+ const back = parentPath(pathname);
+ const isWorkspace = pathname?.startsWith('/configs/');
+
+ return (
+
+
+ {back && (
+
+
+
+ )}
+
+
+ ⌗
+ InGenStudio
+
+ YAML Interface Authoring
+
+
+
+ {isWorkspace && (
+
+
+
+
+
+
+ )}
+
+
+ {/* WorkspaceLayout injects config name, save pill, YAML toggle here via portal */}
+
+
+ );
+}
+
+// This is a pure client SPA (localStorage-backed store, reactflow, portals). Gate the routed
+// content so it mounts only on the client: server render and the first client render both show the
+// same fallback (no hydration mismatch), then the real tree mounts. The brand bar uses no browser
+// APIs, so it renders on the server for an instant, branded first paint.
+//
+// IMPORTANT: this boundary is NOT keyed on the route. ConfigProvider lives below here (in the
+// config layout), so keying on pathname would remount the provider on every navigation — which
+// reloads the persisted model and discards any not-yet-autosaved edit (e.g. a just-added
+// interface). Per-route error reset is handled lower down, below the provider, in WorkspaceLayout.
+
+// The "is the client live" store never changes after hydration, so there is nothing to subscribe
+// to — this returns a no-op unsubscribe. Module-level so the reference stays stable across renders.
+const subscribeNoop = () => () => {};
+
+function AppBody({ children }) {
+ // useSyncExternalStore is the hydration-safe way to ask "are we on the client yet?": the server
+ // snapshot is false and the client snapshot is true, so server render and first client render
+ // agree (no hydration mismatch) and the real tree mounts on the next pass. Doing this with
+ // useState + useEffect would set state synchronously in an effect on every mount.
+ const mounted = useSyncExternalStore(subscribeNoop, () => true, () => false);
+
+ return (
+
+
+ {mounted ? children :
Loading…
}
+
+
+ );
+}
+
+export default function AppShell({ children }) {
+ return (
+
+
+
+
This config could not be loaded. It may have been deleted.
+ Back to configs
+
+ ) : (
+ // Keyed per-route so a crash on one page clears when you navigate — placed BELOW
+ // ConfigProvider so the reset never remounts the document store.
+ {children}
+ )}
+
+ {!yamlCollapsed && }
+
+
+ );
+}
diff --git a/frontend/src/components/layout/navPalette.js b/frontend/src/components/layout/navPalette.js
new file mode 100644
index 00000000..2ee894a7
--- /dev/null
+++ b/frontend/src/components/layout/navPalette.js
@@ -0,0 +1,76 @@
+// InGen Studio — inFlow palette config (pure, JSX-free so it is unit-testable under node --test).
+//
+// Every leaf is actionable: a `subtype` leaf adds a node of that type; an `action` leaf runs a
+// named handler (columns/validations are singletons — append a row / open the existing drawer).
+// Icons are resolved in NavRail by subtype/action — keeping this module free of JSX.
+//
+// Leaf shape: { subtype?, action?, label, color, draggable?, description?, hint?, writes? }
+// The lists are derived from the form schemas, so the menu never drifts from backend support.
+
+import { PRE_PROCESSOR_SCHEMAS, PRE_PROCESSOR_ORDER } from '../../forms/schemas/preProcessorSchemas.js';
+import { OUTPUT_SCHEMAS, OUTPUT_TYPE_OPTIONS } from '../../forms/schemas/outputSchemas.js';
+
+const SOURCE_COLOR = '#3b82f6';
+const TRANSFORM_COLOR = '#f59e0b';
+const COLUMNS_COLOR = '#7853EC';
+const VALIDATION_COLOR = '#10b981';
+const OUTPUT_COLOR = '#ef4444';
+
+// Source order mirrors how often you reach for each (file first); `file` fans out to its file_type
+// variants inside the drawer rather than as separate leaves.
+const SOURCE_ORDER = ['file', 'mysql', 'api', 'json'];
+const SOURCE_LABELS = {
+ file: 'File',
+ mysql: 'Database (MySQL)',
+ api: 'API',
+ json: 'JSON payload',
+};
+
+export function buildPalette() {
+ return [
+ {
+ group: 'Sources',
+ note: 'A pipeline can read many sources; the first is the base input, the rest feed transforms.',
+ nodes: SOURCE_ORDER.map((subtype) => ({
+ subtype,
+ label: SOURCE_LABELS[subtype],
+ color: SOURCE_COLOR,
+ })),
+ },
+ {
+ group: 'Transforms',
+ note: 'Merge, union, filter and reshape steps run in order on the base input.',
+ nodes: PRE_PROCESSOR_ORDER.map((subtype) => {
+ const s = PRE_PROCESSOR_SCHEMAS[subtype];
+ return {
+ subtype,
+ label: s.label,
+ color: TRANSFORM_COLOR,
+ description: s.description,
+ hint: s.hint,
+ writes: (s.yamlFields || []).join(', '),
+ draggable: true, // transforms also support drag-to-canvas
+ };
+ }),
+ },
+ {
+ group: 'Columns',
+ note: 'Columns map source fields to output fields. Add one, then edit the mapping in the drawer.',
+ nodes: [{ action: 'add_column', label: 'Add column', color: COLUMNS_COLOR }],
+ },
+ {
+ group: 'Validations',
+ note: 'Optional great_expectations checks, attached per column. Opens the validations editor.',
+ nodes: [{ action: 'add_validation', label: 'Add validation', color: VALIDATION_COLOR }],
+ },
+ {
+ group: 'Output',
+ note: 'One destination per interface. Use Splitted file or JSON writer for multi-shape output.',
+ nodes: OUTPUT_TYPE_OPTIONS.map((subtype) => ({
+ subtype,
+ label: OUTPUT_SCHEMAS[subtype].label,
+ color: OUTPUT_COLOR,
+ })),
+ },
+ ];
+}
diff --git a/frontend/src/components/layout/navPalette.test.js b/frontend/src/components/layout/navPalette.test.js
new file mode 100644
index 00000000..5cb6de34
--- /dev/null
+++ b/frontend/src/components/layout/navPalette.test.js
@@ -0,0 +1,26 @@
+import { test } from 'node:test';
+import assert from 'node:assert';
+import { buildPalette } from './navPalette.js';
+import { PRE_PROCESSOR_ORDER } from '../../forms/schemas/preProcessorSchemas.js';
+import { OUTPUT_TYPE_OPTIONS } from '../../forms/schemas/outputSchemas.js';
+
+test('palette enumerates exactly the schema-supported types', () => {
+ const groups = Object.fromEntries(buildPalette().map((g) => [g.group, g]));
+
+ assert.deepEqual(
+ groups.Sources.nodes.map((n) => n.subtype),
+ ['file', 'mysql', 'api', 'json'],
+ );
+ assert.deepEqual(groups.Transforms.nodes.map((n) => n.subtype), PRE_PROCESSOR_ORDER);
+ assert.deepEqual(groups.Output.nodes.map((n) => n.subtype), OUTPUT_TYPE_OPTIONS);
+ assert.deepEqual(groups.Columns.nodes.map((n) => n.action), ['add_column']);
+ assert.deepEqual(groups.Validations.nodes.map((n) => n.action), ['add_validation']);
+});
+
+test('every leaf is actionable — no dead info-only chips', () => {
+ for (const g of buildPalette()) {
+ for (const n of g.nodes) {
+ assert.ok(n.subtype || n.action, `stale leaf in ${g.group}: ${n.label}`);
+ }
+ }
+});
diff --git a/frontend/src/components/layout/navPaths.js b/frontend/src/components/layout/navPaths.js
new file mode 100644
index 00000000..804f57f2
--- /dev/null
+++ b/frontend/src/components/layout/navPaths.js
@@ -0,0 +1,14 @@
+// Hierarchical "up" navigation. Browser history (router.back) is unreliable here — the index route
+// redirects, so back can bounce or leave the app. Instead we compute the parent route from the
+// current path: deep pages → their config, a config → the pipelines index, the index → nowhere.
+
+/**
+ * @param {string|null|undefined} pathname
+ * @returns {string|null} the parent route, or null when there is no "up" (the index).
+ */
+export function parentPath(pathname) {
+ if (!pathname || pathname === '/') return null;
+ const m = pathname.match(/^\/configs\/([^/]+)(\/.+)?$/);
+ if (!m) return '/'; // unknown route → home
+ return m[2] ? `/configs/${m[1]}` : '/'; // sub-page → its config; config → home
+}
diff --git a/frontend/src/components/layout/navPaths.test.js b/frontend/src/components/layout/navPaths.test.js
new file mode 100644
index 00000000..29df227d
--- /dev/null
+++ b/frontend/src/components/layout/navPaths.test.js
@@ -0,0 +1,13 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { parentPath } from './navPaths.js';
+
+test('parentPath walks one level up the route hierarchy', () => {
+ assert.equal(parentPath('/'), null); // index has no up
+ assert.equal(parentPath('/configs/cfg_1'), '/'); // config → index
+ assert.equal(parentPath('/configs/cfg_1/run'), '/configs/cfg_1'); // run → config
+ assert.equal(parentPath('/configs/cfg_1/history'), '/configs/cfg_1'); // history → config
+ assert.equal(parentPath('/configs/cfg_1/interfaces/iface_a'), '/configs/cfg_1'); // editor → config
+ assert.equal(parentPath('/something/else'), '/'); // unknown → home
+ assert.equal(parentPath(null), null);
+});
diff --git a/frontend/src/components/run/HistoryView.jsx b/frontend/src/components/run/HistoryView.jsx
new file mode 100644
index 00000000..2e99e570
--- /dev/null
+++ b/frontend/src/components/run/HistoryView.jsx
@@ -0,0 +1,127 @@
+// Execution History (route element for /configs/:configId/history)
+//
+// Lists past RunRecords for this config from HistoryService (localStorage-backed). Expand a run to
+// see its validation results and per-stage outcome. Read-only audit surface.
+
+import { useEffect, useState } from 'react';
+import { useConfig } from '../../state/ConfigContext.jsx';
+import { getServices, getAdapterMode } from '../../services/index.js';
+import { ADAPTER_MODE } from '../../models/constants.js';
+import ValidationResults from './ValidationResults.jsx';
+import ConfirmDialog from '../common/ConfirmDialog.jsx';
+
+const STATUS_PILL = { success: 'pill--ok', partial: 'pill--warn', failed: 'pill--err' };
+const fmt = (iso) => (iso ? iso.replace('T', ' ').slice(0, 19) : '');
+
+export default function HistoryView() {
+ const { model } = useConfig();
+ const configId = model?.meta.id;
+ const [runs, setRuns] = useState(null);
+ const [openId, setOpenId] = useState(null);
+ const [confirmClear, setConfirmClear] = useState(false);
+ const [refreshKey, setRefreshKey] = useState(0);
+ const [error, setError] = useState(null);
+
+ // In http mode run history is server-authoritative and there is no delete endpoint, so clearing
+ // would silently do nothing. Don't offer the action rather than confirm it and no-op.
+ const canClear = getAdapterMode() !== ADAPTER_MODE.HTTP;
+
+ useEffect(() => {
+ let alive = true;
+ if (configId) {
+ getServices().history.list(configId)
+ .then((r) => { if (alive) setRuns(r); })
+ .catch((err) => {
+ if (!alive) return;
+ setRuns([]);
+ setError(err?.message || 'Could not load run history');
+ });
+ }
+ return () => { alive = false; };
+ }, [configId, refreshKey]);
+
+ const refresh = () => setRefreshKey((k) => k + 1);
+
+ const doClear = async () => {
+ try {
+ await getServices().history.clear(configId);
+ } catch (err) {
+ setError(err?.message || 'Could not clear run history');
+ } finally {
+ setOpenId(null);
+ setConfirmClear(false);
+ refresh();
+ }
+ };
+
+ return (
+
+
+
+
Execution history
+
+ {canClear ? 'Past simulated runs for this config (stored locally).' : 'Past runs for this config, recorded by the backend.'}
+
+ );
+}
diff --git a/frontend/src/components/run/OverridesForm.jsx b/frontend/src/components/run/OverridesForm.jsx
new file mode 100644
index 00000000..06b77be8
--- /dev/null
+++ b/frontend/src/components/run/OverridesForm.jsx
@@ -0,0 +1,88 @@
+// Runtime overrides form — maps to the CLI args that don't mutate the saved YAML (run_date,
+// --interfaces subset, --override_params). Submitting starts a (mock) run.
+
+import { useState } from 'react';
+import { useConfig } from '../../state/ConfigContext.jsx';
+import { JsonField } from '../../forms/fields/Fields.jsx';
+
+export default function OverridesForm({ running, onRun, onCancel }) {
+ const { model } = useConfig();
+ const allInterfaces = model?.interfaceOrder ?? [];
+ const [runDate, setRunDate] = useState('');
+ const [selected, setSelected] = useState(() => new Set(allInterfaces));
+ const [overrideParams, setOverrideParams] = useState(undefined);
+ const [queryParams, setQueryParams] = useState(undefined);
+
+ // The model often loads AFTER this form first mounts (allInterfaces was []), which left every
+ // interface unchecked and the Run button disabled. Re-seed the selection (default = all) whenever
+ // the set of interface NAMES changes, using React's render-phase "adjust state on prop change"
+ // pattern (no effect — avoids a cascading-render lint and an extra commit). Keyed on the names so
+ // unrelated model edits don't reset a user's manual selection.
+ const ifaceKey = allInterfaces.join(' ');
+ const [prevIfaceKey, setPrevIfaceKey] = useState(ifaceKey);
+ if (prevIfaceKey !== ifaceKey) {
+ setPrevIfaceKey(ifaceKey);
+ setSelected(new Set(allInterfaces));
+ }
+
+ const toggle = (name) => setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(name)) next.delete(name); else next.add(name);
+ return next;
+ });
+
+ const submit = () => {
+ const interfaces = allInterfaces.filter((n) => selected.has(n));
+ onRun({
+ ...(runDate ? { run_date: runDate } : {}),
+ interfaces,
+ ...(overrideParams ? { override_params: overrideParams } : {}),
+ ...(queryParams ? { query_params: queryParams } : {}),
+ });
+ };
+
+ return (
+
+
+
+
+ interfaces
+
+ {allInterfaces.map((n) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ {running ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/run/RunConsole.jsx b/frontend/src/components/run/RunConsole.jsx
new file mode 100644
index 00000000..d6c3d4de
--- /dev/null
+++ b/frontend/src/components/run/RunConsole.jsx
@@ -0,0 +1,69 @@
+// Run Console (route element for /configs/:configId/run)
+//
+// Composes the RunProvider and lays out the controls + live panels: stage timeline, log stream,
+// validation results. In mock mode RunService.simulate drives everything with no backend; in http
+// mode the same panels render the results streamed back from the FastAPI wrapper.
+
+import { RunProvider, useRun } from '../../state/RunContext.jsx';
+import { getAdapterMode } from '../../services/index.js';
+import { ADAPTER_MODE } from '../../models/constants.js';
+import OverridesForm from './OverridesForm.jsx';
+import StageTimeline from './StageTimeline.jsx';
+import LogStream from './LogStream.jsx';
+import ValidationResults from './ValidationResults.jsx';
+
+const STATUS_PILL = {
+ success: 'pill--ok', partial: 'pill--warn', failed: 'pill--err',
+};
+
+// In HTTP mode the run is real (the wrapper executes `python -m ingen`); only mock mode is simulated.
+const SUBTITLE = {
+ [ADAPTER_MODE.HTTP]: 'Executes the config through the InGen backend. Logs and results are live.',
+ [ADAPTER_MODE.MOCK]: 'Simulated execution — no backend. Logs and results are mocked.',
+};
+
+function Console() {
+ const { status, events, record, start, cancel } = useRun();
+ const subtitle = SUBTITLE[getAdapterMode()] ?? SUBTITLE[ADAPTER_MODE.MOCK];
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/run/ValidationResults.jsx b/frontend/src/components/run/ValidationResults.jsx
new file mode 100644
index 00000000..da87d1ef
--- /dev/null
+++ b/frontend/src/components/run/ValidationResults.jsx
@@ -0,0 +1,45 @@
+// Validation results view — passed/failed/warning expectations with severity. Reused by the Run
+// Console and the History detail. Pure presentation of a ValidationReport.
+
+const STATUS_CLASS = { passed: 'vres--pass', failed: 'vres--fail', warning: 'vres--warn' };
+
+export default function ValidationResults({ report }) {
+ if (!report) return
+ );
+}
diff --git a/frontend/src/components/start/EntryOverlay.jsx b/frontend/src/components/start/EntryOverlay.jsx
new file mode 100644
index 00000000..0924b4f8
--- /dev/null
+++ b/frontend/src/components/start/EntryOverlay.jsx
@@ -0,0 +1,28 @@
+// EntryOverlay — after a source is described, choose how to build: inFlow (visual board) or
+// inChat (assistant). Non-binding — both edit the same config and you can switch anytime.
+
+import { GitBranch, MessageSquare } from 'lucide-react';
+
+export default function EntryOverlay({ onChoose, onBack }) {
+ return (
+
+
+
How do you want to build it?
+
Both edit the same pipeline — switch anytime from the top bar.