diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index bebe1b392d..1948b71bf4 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -7878,6 +7878,7 @@ title = "Pipelines"
newPipeline = "New pipeline"
[portal.pipelines.builder]
+activate = "Activate"
back = "Back to pipelines"
cannotFollow = "Can't take {{produced}}"
chooseAccount = "Choose an account"
@@ -7885,7 +7886,6 @@ chooseDestination = "Choose a destination"
chooseOperation = "Choose what this step does"
chooseSource = "Choose a source"
discard = "Discard changes"
-enabled = "Enabled"
inputs = "Input"
inputSource = "Input source"
inputTrigger = "Trigger"
@@ -7896,6 +7896,8 @@ needsDestination = "No destination chosen"
needsSource = "No source chosen"
needsUpload = "Needs an uploaded file"
noToolMatches = "No tools match your search."
+pause = "Pause"
+rename = "Rename pipeline"
searchTools = "Search tools"
sendToSystem = "Send to another system"
stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}."
@@ -7908,6 +7910,17 @@ uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these
usesDefaults = "Runs with default settings"
viewDefinition = "View definition"
+[portal.pipelines.builder.blocker]
+destination = "Choose a destination"
+heading = "To create this pipeline:"
+incompatible = "Fix steps that can't run in order: {{tools}}"
+name = "Give the pipeline a name"
+saveHeading = "To save your changes:"
+schedule = "Set how often it runs"
+setup = "Finish setting up: {{tools}}"
+source = "Choose an input source"
+upload = "Remove steps that need an uploaded file: {{tools}}"
+
[portal.pipelines.builder.diagnostic]
fan-in = "Combines every incoming file"
fan-out = "Runs once per incoming file"
@@ -7918,12 +7931,12 @@ undeclared-operation = "Can't check what this step accepts"
[portal.pipelines.composer]
addTool = "Add a tool"
-cancel = "Cancel"
create = "Create pipeline"
+createPaused = "Create paused"
editingUnsupported = "Displaying these tool params for editing is not supported yet."
editSource = "Edit source"
name = "Name"
-namePlaceholder = "e.g. Redaction sweep"
+namePlaceholder = "Pipeline name"
noToolSettings = "This tool has no configurable settings."
output = "Destination"
save = "Save changes"
@@ -7955,7 +7968,7 @@ confirm = "Delete"
title = "Delete pipeline?"
[portal.pipelines.detail]
-clearHistory = "Clear history"
+clearHistory = "Process ignored files in source"
delete = "Delete pipeline"
run = "Run now"
@@ -8008,10 +8021,9 @@ completed_one = "Run completed."
completed_other = "All {{count}} runs completed."
empty = "Nothing to run: the sources had no documents to process."
failed = "Run failed: {{error}}"
-historyCleared = "History cleared. The next run reprocesses everything currently in the sources."
inFlight = "Nothing new to run: documents are still being processed from an earlier run."
-parked_one = "Nothing to run: {{count}} document failed previously and is parked. Fix the cause, then clear history to retry it."
-parked_other = "Nothing to run: {{count}} documents failed previously and are parked. Fix the cause, then clear history to retry them."
+parked_one = "Nothing to run: {{count}} document failed previously and is parked. Fix the cause, then reprocess the source to retry it."
+parked_other = "Nothing to run: {{count}} documents failed previously and are parked. Fix the cause, then reprocess the source to retry them."
running = "Run started; still in progress."
timeout = "Run is taking longer than expected; it may still finish in the background."
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.css b/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.css
new file mode 100644
index 0000000000..c0779b9aeb
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.css
@@ -0,0 +1,22 @@
+/**
+ * The "why is this disabled" list, inside a save/create button's tooltip. Left-aligned (a bulleted
+ * list reads oddly centred) and inheriting the tooltip's own colours.
+ */
+
+.portal-pipeline-blockers {
+ text-align: left;
+}
+
+.portal-pipeline-blockers__heading {
+ margin: 0 0 0.25rem;
+ font-weight: 600;
+}
+
+.portal-pipeline-blockers ul {
+ margin: 0;
+ padding-left: 1.1rem;
+}
+
+.portal-pipeline-blockers li {
+ margin: 0.125rem 0;
+}
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.tsx b/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.tsx
new file mode 100644
index 0000000000..adba6ecd26
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.tsx
@@ -0,0 +1,48 @@
+import type { ReactElement } from "react";
+import { Tooltip } from "@mantine/core";
+import "@portal/components/pipelines/PipelineBlockerTooltip.css";
+
+export interface PipelineBlockerTooltipProps {
+ /** Short line above the list, e.g. "To create this pipeline:" / "To save your changes:". */
+ heading: string;
+ /** Everything still owed before the action is possible; empty means the action is allowed. */
+ blockers: string[];
+ /** The disabled control (wrapped so its hover still reaches the tooltip - see below). */
+ children: ReactElement;
+}
+
+/**
+ * Explains why a disabled save/create control can't be used yet, by listing what is still owed.
+ *
+ * A disabled button swallows its own pointer events, so the caller must pass a NON-disabled wrapper
+ * (a span/div around the button) as the child - that wrapper is what the pointer lands on. The
+ * tooltip hides itself when there is nothing to list (the action is allowed, or it is only
+ * mid-save), so callers can wire it unconditionally.
+ */
+export function PipelineBlockerTooltip({
+ heading,
+ blockers,
+ children,
+}: PipelineBlockerTooltipProps) {
+ return (
+
+
{heading}
+
+ {blockers.map((blocker) => (
+
{blocker}
+ ))}
+
+
+ }
+ disabled={blockers.length === 0}
+ position="bottom-end"
+ withinPortal
+ multiline
+ w={280}
+ >
+ {children}
+
+ );
+}
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.css
new file mode 100644
index 0000000000..d3e5e4827c
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.css
@@ -0,0 +1,47 @@
+/**
+ * Create mode's toolbar: name on the left, commit actions on the right - the same shape as the edit
+ * header, so the two modes feel like one page.
+ */
+
+.portal-pipeline-create-header {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+/* Sized to a name, not the width of the page: a title-length field reads as a title, where the old
+ full-bleed input read as a search bar. It gives a little on narrow viewports but never grows to
+ fill the row - the actions on the right anchor the far end instead. */
+.portal-pipeline-create-header__name {
+ flex: 0 1 22rem;
+ min-width: 12rem;
+}
+
+.portal-pipeline-create-header__name input {
+ font-size: 1rem;
+ font-weight: 500;
+}
+
+/* Pinned to the right, mirroring the edit header. Buttons hold their width and the row wraps rather
+ than clipping. */
+.portal-pipeline-create-header__actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-left: auto;
+ flex: none;
+}
+
+/* The create buttons hold their width, so their labels never squash. */
+.portal-pipeline-create-header .sui-btn {
+ flex: none;
+ white-space: nowrap;
+}
+
+/* The two create buttons share one tooltip target, so they sit in their own inline group. */
+.portal-pipeline-create-header__create {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+}
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx
new file mode 100644
index 0000000000..b9717c0848
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx
@@ -0,0 +1,52 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { PipelineCreateHeader } from "@portal/components/pipelines/PipelineCreateHeader";
+
+const meta: Meta = {
+ title: "Portal/Pipelines/PipelineCreateHeader",
+ component: PipelineCreateHeader,
+ parameters: { layout: "padded" },
+};
+export default meta;
+type Story = StoryObj;
+
+const noop = () => {};
+
+/**
+ * The name is live, so the toolbar can be seen as it is filled in. Until it is named (a stand-in for
+ * the app's full validity check) the create buttons are disabled and carry a tooltip of what's owed.
+ */
+function Playground({ initialName }: { initialName: string }) {
+ const [name, setName] = useState(initialName);
+ const blockers =
+ name.trim() === ""
+ ? [
+ "Give the pipeline a name",
+ "Choose an input source",
+ "Choose a destination",
+ ]
+ : [];
+ return (
+
+ );
+}
+
+/** A new pipeline: create is disabled, and hovering it lists what's still needed. */
+export const New: Story = {
+ render: () => ,
+};
+
+/** Named: the create actions become available. */
+export const Named: Story = {
+ render: () => ,
+};
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.test.tsx
new file mode 100644
index 0000000000..c2f1826136
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.test.tsx
@@ -0,0 +1,86 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ fireEvent,
+ render as baseRender,
+ screen,
+} from "@testing-library/react";
+import { PortalTestProviders } from "@portal/test/TestQueryProvider";
+import {
+ PipelineCreateHeader,
+ type PipelineCreateHeaderProps,
+} from "@portal/components/pipelines/PipelineCreateHeader";
+
+const render = (ui: Parameters[0]) =>
+ baseRender(ui, { wrapper: PortalTestProviders });
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+function renderHeader(overrides: Partial = {}) {
+ const handlers = {
+ onNameChange: vi.fn(),
+ onCreate: vi.fn(),
+ onCreatePaused: vi.fn(),
+ onBack: vi.fn(),
+ };
+ render(
+ ,
+ );
+ return handlers;
+}
+
+describe("PipelineCreateHeader", () => {
+ it("edits the pipeline's name", () => {
+ const handlers = renderHeader();
+ fireEvent.change(
+ screen.getByRole("textbox", { name: "portal.pipelines.composer.name" }),
+ { target: { value: "Renamed" } },
+ );
+ expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed");
+ });
+
+ it("creates the pipeline, live or paused, and backs out", () => {
+ const handlers = renderHeader();
+ fireEvent.click(screen.getByText("portal.pipelines.composer.create"));
+ expect(handlers.onCreate).toHaveBeenCalled();
+ fireEvent.click(screen.getByText("portal.pipelines.composer.createPaused"));
+ expect(handlers.onCreatePaused).toHaveBeenCalled();
+ fireEvent.click(screen.getByLabelText("portal.pipelines.builder.back"));
+ expect(handlers.onBack).toHaveBeenCalled();
+ });
+
+ it("blocks both create actions until the pipeline is valid", () => {
+ renderHeader({ canSave: false, blockers: ["Choose a destination"] });
+ expect(
+ screen.getByText("portal.pipelines.composer.create").closest("button"),
+ ).toBeDisabled();
+ expect(
+ screen
+ .getByText("portal.pipelines.composer.createPaused")
+ .closest("button"),
+ ).toBeDisabled();
+ });
+
+ it("explains, on hover, why the create buttons are disabled", async () => {
+ renderHeader({
+ canSave: false,
+ blockers: ["Give the pipeline a name", "Choose a destination"],
+ });
+ const group = document.querySelector(
+ ".portal-pipeline-create-header__create",
+ ) as HTMLElement;
+ fireEvent.pointerEnter(group);
+ fireEvent.mouseEnter(group);
+ expect(await screen.findByText("Choose a destination")).toBeInTheDocument();
+ expect(screen.getByText("Give the pipeline a name")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.tsx
new file mode 100644
index 0000000000..b4e3fa7951
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.tsx
@@ -0,0 +1,90 @@
+import { useTranslation } from "react-i18next";
+import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
+import { ActionIcon, Button, Input } from "@app/ui";
+import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip";
+import "@portal/components/pipelines/PipelineCreateHeader.css";
+
+export interface PipelineCreateHeaderProps {
+ name: string;
+ onNameChange: (name: string) => void;
+
+ canSave: boolean;
+ /** Everything still owed before the pipeline can be created, shown on the disabled create button. */
+ blockers: string[];
+ saving: boolean;
+ /** Which create action is mid-save, so only the button that was clicked shows its spinner. */
+ pendingCreateEnabled: boolean | null;
+ onCreate: () => void;
+ onCreatePaused: () => void;
+ onBack: () => void;
+}
+
+/**
+ * The create-mode toolbar. Mirrors the edit header's shape - a back arrow and the name on the left,
+ * actions on the right - so the two modes read as the same page in two states rather than two
+ * different screens. The right commits the pipeline live or paused; while it can't yet, the disabled
+ * create buttons carry a tooltip listing exactly what is still owed, so "disabled" is never a dead end.
+ */
+export function PipelineCreateHeader({
+ name,
+ onNameChange,
+ canSave,
+ blockers,
+ saving,
+ pendingCreateEnabled,
+ onCreate,
+ onCreatePaused,
+ onBack,
+}: PipelineCreateHeaderProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ onNameChange(e.target.value)}
+ />
+
+
+ {/* The pair share one tooltip target because a disabled button swallows its own hover - the
+ wrapper is what the pointer lands on. */}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.css
new file mode 100644
index 0000000000..a83d3ff6ae
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.css
@@ -0,0 +1,67 @@
+/**
+ * Edit mode's toolbar: identity on the left, operational actions on the right.
+ */
+
+.portal-pipeline-edit-header {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.portal-pipeline-edit-header__identity {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ min-width: 0;
+ flex: 1 1 16rem;
+}
+
+/* The name is the page's title. It takes the room the identity row leaves and truncates rather than
+ wrapping, so a long name never pushes the pencil out of reach. */
+.portal-pipeline-edit-header__title {
+ margin: 0;
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--c-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+
+.portal-pipeline-edit-header__name-input {
+ flex: 1 1 16rem;
+ min-width: 12rem;
+}
+
+.portal-pipeline-edit-header__name-input input {
+ font-size: 1.125rem;
+ font-weight: 600;
+}
+
+/* Never let the labels squash: buttons hold their width and the row wraps instead of clipping. */
+.portal-pipeline-edit-header__actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: none;
+}
+
+.portal-pipeline-edit-header__actions .sui-btn {
+ flex: none;
+ white-space: nowrap;
+}
+
+/* Save is wrapped so its disabled hover reaches the blocker tooltip; the wrapper must not shrink. */
+.portal-pipeline-edit-header__save {
+ display: inline-flex;
+ flex: none;
+}
+
+/* Destructive item in the overflow tray: red label and icon, so it reads as the exception among
+ the neutral entries above it. */
+.portal-pipeline-edit-header__delete-item .sui-dd__item-label,
+.portal-pipeline-edit-header__delete-item .sui-dd__item-leading {
+ color: var(--c-danger);
+}
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.stories.tsx
new file mode 100644
index 0000000000..538024e933
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.stories.tsx
@@ -0,0 +1,71 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { PipelineEditHeader } from "@portal/components/pipelines/PipelineEditHeader";
+
+const meta: Meta = {
+ title: "Portal/Pipelines/PipelineEditHeader",
+ component: PipelineEditHeader,
+ parameters: { layout: "padded" },
+};
+export default meta;
+type Story = StoryObj;
+
+const noop = () => {};
+
+/** The name and the pause/activate state are live, so both can be exercised. */
+function Playground({
+ initialName,
+ initialEnabled = true,
+ canSave = true,
+ blockers = [],
+}: {
+ initialName: string;
+ initialEnabled?: boolean;
+ canSave?: boolean;
+ blockers?: string[];
+}) {
+ const [name, setName] = useState(initialName);
+ const [enabled, setEnabled] = useState(initialEnabled);
+ return (
+ setEnabled((e) => !e)}
+ togglingEnabled={false}
+ onBack={noop}
+ canSave={canSave}
+ blockers={blockers}
+ saving={false}
+ onSave={noop}
+ onRun={noop}
+ running={false}
+ onReprocess={noop}
+ reprocessing={false}
+ onDelete={noop}
+ />
+ );
+}
+
+/** A live pipeline: the toggle offers to pause it. */
+export const Active: Story = {
+ render: () => ,
+};
+
+/** A paused pipeline: the toggle offers to activate it. */
+export const Paused: Story = {
+ render: () => (
+
+ ),
+};
+
+/** Edits that cannot yet be saved: Save is disabled and hovering it lists what's still needed. */
+export const CannotSave: Story = {
+ render: () => (
+
+ ),
+};
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx
new file mode 100644
index 0000000000..e216dbb292
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx
@@ -0,0 +1,156 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ fireEvent,
+ render as baseRender,
+ screen,
+} from "@testing-library/react";
+import { PortalTestProviders } from "@portal/test/TestQueryProvider";
+import {
+ PipelineEditHeader,
+ type PipelineEditHeaderProps,
+} from "@portal/components/pipelines/PipelineEditHeader";
+
+const render = (ui: Parameters[0]) =>
+ baseRender(ui, { wrapper: PortalTestProviders });
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+function renderHeader(overrides: Partial = {}) {
+ const handlers = {
+ onNameChange: vi.fn(),
+ onTogglePause: vi.fn(),
+ onBack: vi.fn(),
+ onSave: vi.fn(),
+ onRun: vi.fn(),
+ onReprocess: vi.fn(),
+ onDelete: vi.fn(),
+ };
+ render(
+ ,
+ );
+ return handlers;
+}
+
+describe("PipelineEditHeader", () => {
+ it("shows the name as the title and renames it in place", () => {
+ const handlers = renderHeader();
+ expect(screen.getByText("Claims redaction")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByLabelText("portal.pipelines.builder.rename"));
+ const input = screen.getByRole("textbox", {
+ name: "portal.pipelines.composer.name",
+ });
+ fireEvent.change(input, { target: { value: "Renamed" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed");
+ });
+
+ it("abandons a rename on Escape, keeping the old name", () => {
+ const handlers = renderHeader();
+ fireEvent.click(screen.getByLabelText("portal.pipelines.builder.rename"));
+ const input = screen.getByRole("textbox", {
+ name: "portal.pipelines.composer.name",
+ });
+ fireEvent.change(input, { target: { value: "Discarded" } });
+ fireEvent.keyDown(input, { key: "Escape" });
+ // Escape must not commit, even via the blur that unmounting the field fires in a real browser.
+ fireEvent.blur(input);
+ expect(handlers.onNameChange).not.toHaveBeenCalled();
+ expect(screen.getByText("Claims redaction")).toBeInTheDocument();
+ });
+
+ it("commits a rename when focus leaves the field", () => {
+ const handlers = renderHeader();
+ fireEvent.click(screen.getByLabelText("portal.pipelines.builder.rename"));
+ const input = screen.getByRole("textbox", {
+ name: "portal.pipelines.composer.name",
+ });
+ fireEvent.change(input, { target: { value: "Renamed" } });
+ fireEvent.blur(input);
+ expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed");
+ });
+
+ it("offers to pause a live pipeline and to activate a paused one", () => {
+ const handlers = renderHeader();
+ fireEvent.click(screen.getByText("portal.pipelines.builder.pause"));
+ expect(handlers.onTogglePause).toHaveBeenCalled();
+
+ renderHeader({ enabled: false });
+ expect(
+ screen.getByText("portal.pipelines.builder.activate"),
+ ).toBeInTheDocument();
+ });
+
+ it("runs the saved pipeline from the row", () => {
+ const handlers = renderHeader();
+ fireEvent.click(screen.getByText("portal.pipelines.detail.run"));
+ expect(handlers.onRun).toHaveBeenCalled();
+ });
+
+ it("keeps clear-history and delete behind the overflow tray", () => {
+ const handlers = renderHeader();
+ // Not in the row itself...
+ expect(
+ screen.queryByText("portal.pipelines.detail.clearHistory"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText("portal.pipelines.detail.delete"),
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(
+ screen.getByLabelText("portal.pipelines.builder.moreActions"),
+ );
+ fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory"));
+ expect(handlers.onReprocess).toHaveBeenCalled();
+
+ fireEvent.click(
+ screen.getByLabelText("portal.pipelines.builder.moreActions"),
+ );
+ fireEvent.click(screen.getByText("portal.pipelines.detail.delete"));
+ expect(handlers.onDelete).toHaveBeenCalled();
+ });
+
+ it("blocks saving until the edits are valid", () => {
+ renderHeader({ canSave: false });
+ expect(
+ screen.getByText("portal.pipelines.composer.save").closest("button"),
+ ).toBeDisabled();
+ });
+
+ it("cannot pause while a save is committing", () => {
+ renderHeader({ saving: true });
+ expect(
+ screen.getByText("portal.pipelines.builder.pause").closest("button"),
+ ).toBeDisabled();
+ });
+
+ it("cannot save while a pause is committing", () => {
+ renderHeader({ togglingEnabled: true });
+ expect(
+ screen.getByText("portal.pipelines.composer.save").closest("button"),
+ ).toBeDisabled();
+ });
+
+ it("explains, on hover, why Save is disabled", async () => {
+ renderHeader({ canSave: false, blockers: ["Choose a destination"] });
+ const save = document.querySelector(
+ ".portal-pipeline-edit-header__save",
+ ) as HTMLElement;
+ fireEvent.pointerEnter(save);
+ fireEvent.mouseEnter(save);
+ expect(await screen.findByText("Choose a destination")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx
new file mode 100644
index 0000000000..7ee2fbd173
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx
@@ -0,0 +1,235 @@
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
+import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
+import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded";
+import PauseRoundedIcon from "@mui/icons-material/PauseRounded";
+import PowerSettingsNewRoundedIcon from "@mui/icons-material/PowerSettingsNewRounded";
+import ReplayRoundedIcon from "@mui/icons-material/ReplayRounded";
+import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
+import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded";
+import { ActionIcon, Button, Dropdown, Input } from "@app/ui";
+import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip";
+import "@portal/components/pipelines/PipelineEditHeader.css";
+
+export interface PipelineEditHeaderProps {
+ name: string;
+ onNameChange: (name: string) => void;
+
+ /** The pipeline's live state. Toggling it takes effect immediately, not on save. */
+ enabled: boolean;
+ onTogglePause: () => void;
+ togglingEnabled: boolean;
+
+ onBack: () => void;
+
+ canSave: boolean;
+ /** Everything still owed before the edits can be saved, shown on the disabled Save button. */
+ blockers: string[];
+ saving: boolean;
+ onSave: () => void;
+
+ /** Run the saved pipeline against its real input, delivering to its real destination. */
+ onRun: () => void;
+ running: boolean;
+ /** Reprocess everything in the sources: clears the processed record, then runs at once. */
+ onReprocess: () => void;
+ reprocessing: boolean;
+ onDelete: () => void;
+}
+
+/**
+ * Edit mode's toolbar over an existing, live pipeline. The left is what it *is* - a back arrow, its
+ * name as the page title, a pencil to rename in place. The right is what you can *do to it*: pause
+ * or activate it (an operational toggle that acts at once, matching the Policies vocabulary), run it
+ * now, and - behind an overflow, since they are rare or destructive - reprocess its sources or delete
+ * it. Saving the chain edits is the primary action, on the far right. (Reading the definition is an
+ * inspect action, so it lives in the graph toolbar beside Test, not here.)
+ */
+export function PipelineEditHeader({
+ name,
+ onNameChange,
+ enabled,
+ onTogglePause,
+ togglingEnabled,
+ onBack,
+ canSave,
+ blockers,
+ saving,
+ onSave,
+ onRun,
+ running,
+ onReprocess,
+ reprocessing,
+ onDelete,
+}: PipelineEditHeaderProps) {
+ const { t } = useTranslation();
+ const [renaming, setRenaming] = useState(false);
+ const [draft, setDraft] = useState(name);
+ const inputRef = useRef(null);
+ // Enter and Escape both end the rename, which unmounts the input - and unmounting a focused input
+ // fires blur in a real browser (jsdom does not). Without this guard that blur would re-run the
+ // commit, so Escape would save the very draft it was meant to discard. The key handler sets this so
+ // the trailing blur is ignored; a plain click-away leaves it false and blur commits as normal.
+ const keyHandledRef = useRef(false);
+
+ useEffect(() => {
+ if (renaming) inputRef.current?.select();
+ }, [renaming]);
+
+ function startRename() {
+ keyHandledRef.current = false;
+ setDraft(name);
+ setRenaming(true);
+ }
+
+ // End the rename, committing the draft only when asked and only if non-empty (an all-whitespace
+ // rename would leave the pipeline titleless).
+ function finishRename(commit: boolean) {
+ keyHandledRef.current = true;
+ if (commit) {
+ const next = draft.trim();
+ if (next) onNameChange(next);
+ }
+ setRenaming(false);
+ }
+
+ // Clicking away commits; the unmount-triggered blur that follows a key press does not (the key
+ // already decided the outcome).
+ function handleBlur() {
+ if (keyHandledRef.current) {
+ keyHandledRef.current = false;
+ return;
+ }
+ finishRename(true);
+ }
+
+ return (
+
+
+ {/* Pause and Save both write the whole policy, so they are mutually exclusive: neither can
+ start while the other is committing, or the two writes race and the loser's version wins. */}
+
+ ) : (
+
+ )
+ }
+ >
+ {enabled
+ ? t("portal.pipelines.builder.pause")
+ : t("portal.pipelines.builder.activate")}
+
+
+ {/* Run and Reprocess both start a run, so only one at a time: each is disabled while the
+ other is in flight, matching the handler guards (a click otherwise silently no-ops). */}
+
+ }
+ >
+ {t("portal.pipelines.detail.run")}
+
+
+ {/* Rare and destructive actions kept off the row so they do not compete with running. */}
+
+
+
+
+
+
+
+ }
+ >
+ {t("portal.pipelines.detail.clearHistory")}
+
+
+
+ }
+ >
+ {t("portal.pipelines.detail.delete")}
+
+
+
+
+ {/* Wrapped in a span so the disabled button's hover still reaches the tooltip. */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.css b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.css
new file mode 100644
index 0000000000..9dc2d53bec
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.css
@@ -0,0 +1,48 @@
+/**
+ * The test control and the last run's outcome, directly above the graph.
+ */
+
+.portal-pipeline-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+/* Reading the definition sits at the far end of the bar, opposite Test. */
+.portal-pipeline-toolbar__definition {
+ margin-left: auto;
+}
+
+/* The last test run's outcome, beside the button that started it. Whole-pipeline, because the
+ backend reports one flat file list plus the step it stopped at - nothing per node to attach. */
+.portal-pipeline-toolbar__result {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.portal-pipeline-toolbar__result-status {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.8125rem;
+ color: var(--c-text);
+}
+
+.portal-pipeline-toolbar__result-icon.is-ok {
+ color: var(--c-success);
+}
+
+.portal-pipeline-toolbar__result-icon.is-bad {
+ color: var(--c-danger);
+}
+
+/* Why the run failed, shown inline in the strip. Neutral text (the icon already carries the tone);
+ it may wrap to keep a long backend message readable rather than clipping it. */
+.portal-pipeline-toolbar__result-error {
+ font-size: 0.8125rem;
+ color: var(--c-text-muted);
+ min-width: 0;
+}
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.stories.tsx
new file mode 100644
index 0000000000..359aa9bfa1
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.stories.tsx
@@ -0,0 +1,54 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { PipelineGraphToolbar } from "@portal/components/pipelines/PipelineGraphToolbar";
+
+const meta: Meta = {
+ title: "Portal/Pipelines/PipelineGraphToolbar",
+ component: PipelineGraphToolbar,
+ parameters: { layout: "padded" },
+ args: {
+ stepCount: 2,
+ testing: false,
+ runResult: null,
+ onTest: () => {},
+ onDownloadOutput: () => {},
+ onViewDefinition: () => {},
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+/** Idle: just the test control. */
+export const Idle: Story = {};
+
+/** A chain with no steps cannot be tested. */
+export const NoSteps: Story = { args: { stepCount: 0 } };
+
+/** Mid test-run. */
+export const Testing: Story = { args: { testing: true } };
+
+/** After a completed run: the outcome and its files sit beside the button. */
+export const Completed: Story = {
+ args: {
+ runResult: {
+ status: "completed",
+ completedSteps: 3,
+ stepCount: 3,
+ outputs: [
+ { fileId: "f1", fileName: "claim-redacted.pdf" },
+ { fileId: "f2", fileName: null },
+ ],
+ },
+ },
+};
+
+/** A failed run: the summary and the failure reason. */
+export const Failed: Story = {
+ args: {
+ runResult: {
+ status: "failed",
+ completedSteps: 1,
+ stepCount: 3,
+ error: "OCR failed: unreadable page",
+ },
+ },
+};
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.test.tsx
new file mode 100644
index 0000000000..4aafb27766
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.test.tsx
@@ -0,0 +1,103 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ fireEvent,
+ render as baseRender,
+ screen,
+} from "@testing-library/react";
+import { PortalTestProviders } from "@portal/test/TestQueryProvider";
+import {
+ PipelineGraphToolbar,
+ type PipelineGraphToolbarProps,
+} from "@portal/components/pipelines/PipelineGraphToolbar";
+
+const render = (ui: Parameters[0]) =>
+ baseRender(ui, { wrapper: PortalTestProviders });
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+function renderToolbar(overrides: Partial = {}) {
+ const handlers = {
+ onTest: vi.fn(),
+ onDownloadOutput: vi.fn(),
+ onViewDefinition: vi.fn(),
+ };
+ render(
+ ,
+ );
+ return handlers;
+}
+
+describe("PipelineGraphToolbar", () => {
+ it("hands the chosen file to the test run", () => {
+ const handlers = renderToolbar();
+ const file = new File(["x"], "claim.pdf", { type: "application/pdf" });
+ const input =
+ document.querySelector('input[type="file"]');
+ expect(input).not.toBeNull();
+ fireEvent.change(input as HTMLInputElement, { target: { files: [file] } });
+ expect(handlers.onTest).toHaveBeenCalledWith(file);
+ });
+
+ it("will not offer a test run on a chain with no steps", () => {
+ renderToolbar({ stepCount: 0 });
+ expect(
+ screen.getByText("portal.pipelines.builder.testRun").closest("button"),
+ ).toBeDisabled();
+ });
+
+ it("opens the definition from its icon", () => {
+ const handlers = renderToolbar();
+ fireEvent.click(
+ screen.getByLabelText("portal.pipelines.builder.viewDefinition"),
+ );
+ expect(handlers.onViewDefinition).toHaveBeenCalled();
+ });
+
+ it("shows no result strip until a test has been run", () => {
+ renderToolbar();
+ expect(
+ screen.queryByText(/portal.pipelines.inspector.status/),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows why a test run failed, not only that it did", () => {
+ renderToolbar({
+ runResult: {
+ status: "failed",
+ completedSteps: 1,
+ stepCount: 3,
+ error: "OCR failed: unreadable page",
+ },
+ });
+ expect(screen.getByText("OCR failed: unreadable page")).toBeInTheDocument();
+ });
+
+ it("reports a finished run and downloads the file clicked", () => {
+ const handlers = renderToolbar({
+ runResult: {
+ status: "completed",
+ completedSteps: 2,
+ stepCount: 2,
+ outputs: [
+ { fileId: "f1", fileName: "claim.pdf" },
+ { fileId: "f2", fileName: null },
+ ],
+ },
+ });
+ fireEvent.click(screen.getByText("claim.pdf"));
+ expect(handlers.onDownloadOutput).toHaveBeenCalledWith({
+ fileId: "f1",
+ fileName: "claim.pdf",
+ });
+ // A file the backend did not name still has to be reachable.
+ expect(screen.getByText("f2")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.tsx b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.tsx
new file mode 100644
index 0000000000..30513f6a83
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.tsx
@@ -0,0 +1,147 @@
+import { useTranslation } from "react-i18next";
+import { Tooltip } from "@mantine/core";
+import ScienceOutlinedIcon from "@mui/icons-material/ScienceOutlined";
+import CheckCircleOutlineRoundedIcon from "@mui/icons-material/CheckCircleOutlineRounded";
+import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded";
+import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded";
+import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
+import { ActionIcon, Button, FilePicker, Spinner } from "@app/ui";
+import { type RunOutputFile } from "@portal/api/pipelines";
+import "@portal/components/pipelines/PipelineGraphToolbar.css";
+
+/**
+ * A test run's outcome. Whole-pipeline, not per-node: the backend reports one flat list of files
+ * plus the step it stopped at, so there is no per-node output to attach to a node.
+ */
+export interface RunResultSummary {
+ status: "running" | "completed" | "failed";
+ completedSteps: number;
+ stepCount: number;
+ error?: string | null;
+ outputs?: RunOutputFile[];
+}
+
+export interface PipelineGraphToolbarProps {
+ /** How many steps the chain has, so an empty pipeline cannot offer a test that does nothing. */
+ stepCount: number;
+ /** Run the steps as they stand against one uploaded file, without saving or delivering. */
+ onTest: (file: File) => void;
+ testing: boolean;
+ /** The last test run in this session, or null if there has not been one. */
+ runResult: RunResultSummary | null;
+ onDownloadOutput: (output: RunOutputFile) => void;
+ /** Opens the definition (JSON + cURL) - an inspect action, sibling to Test, hence its home here. */
+ onViewDefinition: () => void;
+}
+
+/**
+ * The graph's own toolbar, above the canvas in both create and edit. It gathers the two ways to
+ * *inspect* what you are building - testing the chain against one file, and reading its definition -
+ * as opposed to committing (Save/Create) or operating on the live pipeline (Run now). A test run's
+ * progress shows on the graph's nodes, so the strip that summarises it belongs next to the graph too.
+ */
+export function PipelineGraphToolbar({
+ stepCount,
+ onTest,
+ testing,
+ runResult,
+ onDownloadOutput,
+ onViewDefinition,
+}: PipelineGraphToolbarProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ file && onTest(file)}
+ leftSection={}
+ >
+ {t("portal.pipelines.builder.testRun")}
+
+
+ {runResult && (
+
+ )}
+
+ {/* The graph is the visual definition; reading it as JSON/cURL sits at the far end of its bar. */}
+
+
+
+
+
+
+ );
+}
+
+interface RunResultStripProps {
+ result: RunResultSummary;
+ onDownload: (output: RunOutputFile) => void;
+}
+
+/** What the last test run did, beside the button that started it. */
+function RunResultStrip({ result, onDownload }: RunResultStripProps) {
+ const { t } = useTranslation();
+ const outputs = result.outputs ?? [];
+
+ return (
+
+
+ {/* The reason it failed, where the failure is announced - not only on the node, which the user
+ has to know to click. */}
+ {result.status === "failed" && result.error && (
+
+ {result.error}
+
+ )}
+
+ {outputs.map((output) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineHeader.css
deleted file mode 100644
index f559e42795..0000000000
--- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.css
+++ /dev/null
@@ -1,133 +0,0 @@
-/**
- * The builder's opening section: identity above the rule, actions below it.
- */
-
-.portal-pipeline-header {
- display: flex;
- flex-direction: column;
- gap: 0.875rem;
- padding: 1.125rem;
- background: var(--c-surface);
- border: 1px solid var(--c-border-subtle);
- border-radius: var(--radius-lg);
-}
-
-/* Leaving the page and saving it are the same kind of decision, so they share a row - and the back
- link is short, so the save pair always has room beside it. */
-.portal-pipeline-header__top {
- display: flex;
- align-items: center;
- gap: 1rem;
- flex-wrap: wrap;
-}
-
-/* The back link is the shared Button restyled to a plain link, so re-assert that over the
- design-system base (which imposes a fixed height, its own padding and an accent colour). */
-.portal-pipeline-header__back.sui-btn {
- height: auto;
- min-height: 0;
- padding: 0;
- font-size: 0.8125rem;
- font-weight: 400;
- color: var(--c-text-muted);
-}
-
-.portal-pipeline-header__back.sui-btn:hover {
- background: none;
- color: var(--c-text);
-}
-
-.portal-pipeline-header__identity {
- display: flex;
- align-items: center;
- gap: 1.25rem;
- flex-wrap: wrap;
-}
-
-/* The shared Checkbox aligns its box to the top of the first text line, with a nudge tuned for its
- own font size - that is for the label-plus-description case. This one is a single line, so centre
- the box on it and leave the component's sizing alone (overriding the font size shifts the line
- box and leaves the tick floating high). */
-.portal-pipeline-header__enabled.sui-check {
- flex: none;
- align-items: center;
-}
-
-.portal-pipeline-header__enabled.sui-check .sui-check__box {
- margin-top: 0;
-}
-
-/* The name is the page's title, so it takes the room and reads at title size. */
-.portal-pipeline-header__name {
- flex: 1 1 16rem;
- min-width: 12rem;
-}
-
-.portal-pipeline-header__name input {
- font-size: 1rem;
- font-weight: 500;
-}
-
-/* Never let the labels squash: buttons hold their width and the row wraps instead of clipping. */
-.portal-pipeline-header__save {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- margin-left: auto;
- flex: none;
-}
-
-.portal-pipeline-header__save .sui-btn {
- flex: none;
- white-space: nowrap;
-}
-
-/* Operational actions: what you can do to this pipeline, kept off the identity row. */
-.portal-pipeline-header__actions {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- flex-wrap: wrap;
- padding-top: 0.875rem;
- border-top: 1px solid var(--c-border-subtle);
-}
-
-/* Destructive, so it sits away from the rest rather than next in line. */
-.portal-pipeline-header__delete.sui-btn {
- margin-left: auto;
-}
-
-/* The last test run's outcome, beside the button that started it. Whole-pipeline, because the
- backend reports one flat file list plus the step it stopped at - nothing per node to attach. */
-.portal-pipeline-header__result {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- flex-wrap: wrap;
- padding-top: 0.875rem;
- border-top: 1px solid var(--c-border-subtle);
-}
-
-.portal-pipeline-header__result-status {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- font-size: 0.8125rem;
- color: var(--c-text);
-}
-
-.portal-pipeline-header__result-icon.is-ok {
- color: var(--c-success);
-}
-
-.portal-pipeline-header__result-icon.is-bad {
- color: var(--c-danger);
-}
-
-/* Why the run failed, shown inline in the strip. Neutral text (the icon already carries the tone);
- it may wrap to keep a long backend message readable rather than clipping it. */
-.portal-pipeline-header__result-error {
- font-size: 0.8125rem;
- color: var(--c-text-muted);
- min-width: 0;
-}
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx
deleted file mode 100644
index 41c73a5ade..0000000000
--- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { useState } from "react";
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import {
- PipelineHeader,
- type RunResultSummary,
-} from "@portal/components/pipelines/PipelineHeader";
-
-const meta: Meta = {
- title: "Portal/Pipelines/PipelineHeader",
- component: PipelineHeader,
- parameters: { layout: "padded" },
-};
-export default meta;
-type Story = StoryObj;
-
-const noop = () => {};
-
-/** The name and the enabled switch are live, so the section can be seen in both states. */
-function Playground({
- initialName,
- isEdit,
- initialEnabled = true,
- runResult = null,
- ...rest
-}: {
- initialName: string;
- isEdit: boolean;
- initialEnabled?: boolean;
- runResult?: RunResultSummary | null;
- saving?: boolean;
- testing?: boolean;
- running?: boolean;
- canSave?: boolean;
- stepCount?: number;
-}) {
- const [name, setName] = useState(initialName);
- const [enabled, setEnabled] = useState(initialEnabled);
- return (
-
- );
-}
-
-/** An existing pipeline: everything is available. */
-export const Editing: Story = {
- render: () => ,
-};
-
-/**
- * A pipeline that has never been saved. It can still be tested against a file, but there is
- * nothing yet to run on a schedule, clear history for, or delete.
- */
-export const New: Story = {
- render: () => ,
-};
-
-/** Paused: the pipeline exists but its trigger will not fire. */
-export const Paused: Story = {
- render: () => (
-
- ),
-};
-
-/** Mid test-run: the picker shows its own progress while the graph shows the steps. */
-export const Testing: Story = {
- render: () => ,
-};
-
-/** After a test run: the outcome and its files sit beside the button that started them. */
-export const WithRunResult: Story = {
- render: () => (
-
- ),
-};
-
-/** A failed run: the summary is here, the failing step's own message is on its node. */
-export const WithFailedRun: Story = {
- render: () => (
-
- ),
-};
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx
deleted file mode 100644
index 2c5552be07..0000000000
--- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import {
- fireEvent,
- render as baseRender,
- screen,
-} from "@testing-library/react";
-import { PortalTestProviders } from "@portal/test/TestQueryProvider";
-import {
- PipelineHeader,
- type PipelineHeaderProps,
-} from "@portal/components/pipelines/PipelineHeader";
-
-const render = (ui: Parameters[0]) =>
- baseRender(ui, { wrapper: PortalTestProviders });
-
-vi.mock("react-i18next", () => ({
- useTranslation: () => ({ t: (key: string) => key }),
-}));
-
-function renderHeader(overrides: Partial = {}) {
- const handlers = {
- onNameChange: vi.fn(),
- onEnabledChange: vi.fn(),
- onSave: vi.fn(),
- onCancel: vi.fn(),
- onBack: vi.fn(),
- onTest: vi.fn(),
- onRun: vi.fn(),
- onClearHistory: vi.fn(),
- onDelete: vi.fn(),
- onViewDefinition: vi.fn(),
- onDownloadOutput: vi.fn(),
- };
- render(
- ,
- );
- return handlers;
-}
-
-describe("PipelineHeader", () => {
- it("edits the pipeline's name and enabled state", () => {
- const handlers = renderHeader();
- fireEvent.change(
- screen.getByRole("textbox", { name: "portal.pipelines.composer.name" }),
- { target: { value: "Renamed" } },
- );
- expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed");
-
- fireEvent.click(screen.getByRole("checkbox"));
- expect(handlers.onEnabledChange).toHaveBeenCalledWith(false);
- });
-
- it("offers run, clear history and delete only once the pipeline exists", () => {
- renderHeader({ isEdit: false });
- expect(
- screen.queryByText("portal.pipelines.detail.run"),
- ).not.toBeInTheDocument();
- expect(
- screen.queryByText("portal.pipelines.detail.delete"),
- ).not.toBeInTheDocument();
- // A test run needs no saved record, so it stays: it is how you check the steps as you build.
- expect(
- screen.getByText("portal.pipelines.builder.testRun"),
- ).toBeInTheDocument();
- });
-
- it("labels the save action for what it will do", () => {
- renderHeader({ isEdit: false });
- expect(
- screen.getByText("portal.pipelines.composer.create"),
- ).toBeInTheDocument();
- expect(
- screen.queryByText("portal.pipelines.composer.save"),
- ).not.toBeInTheDocument();
- });
-
- it("blocks saving until the pipeline is valid", () => {
- renderHeader({ canSave: false });
- expect(
- screen.getByText("portal.pipelines.composer.save").closest("button"),
- ).toBeDisabled();
- });
-
- it("hands the chosen file to the test run", () => {
- const handlers = renderHeader();
- const file = new File(["x"], "claim.pdf", { type: "application/pdf" });
- const input =
- document.querySelector('input[type="file"]');
- expect(input).not.toBeNull();
- fireEvent.change(input as HTMLInputElement, { target: { files: [file] } });
- expect(handlers.onTest).toHaveBeenCalledWith(file);
- });
-
- it("will not offer a test run on a chain with no steps", () => {
- renderHeader({ stepCount: 0 });
- expect(
- screen.getByText("portal.pipelines.builder.testRun").closest("button"),
- ).toBeDisabled();
- });
-
- it("shows why a test run failed, not only that it did", () => {
- renderHeader({
- runResult: {
- status: "failed",
- completedSteps: 1,
- stepCount: 3,
- error: "OCR failed: unreadable page",
- },
- });
- expect(screen.getByText("OCR failed: unreadable page")).toBeInTheDocument();
- });
-
- it("runs and deletes from the row, clears history from the tray", () => {
- const handlers = renderHeader();
- fireEvent.click(screen.getByText("portal.pipelines.detail.run"));
- expect(handlers.onRun).toHaveBeenCalled();
- fireEvent.click(screen.getByText("portal.pipelines.detail.delete"));
- expect(handlers.onDelete).toHaveBeenCalled();
-
- fireEvent.click(
- screen.getByLabelText("portal.pipelines.builder.moreActions"),
- );
- fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory"));
- expect(handlers.onClearHistory).toHaveBeenCalled();
- });
-
- it("leaves the page through cancel and back", () => {
- const handlers = renderHeader();
- fireEvent.click(screen.getByText("portal.pipelines.composer.cancel"));
- expect(handlers.onCancel).toHaveBeenCalled();
- fireEvent.click(screen.getByText("portal.pipelines.builder.back"));
- expect(handlers.onBack).toHaveBeenCalled();
- });
-
- it("keeps the occasional actions out of the row, behind a tray", () => {
- renderHeader();
- // Running and testing earn a button each; reading the definition and wiping history do not.
- expect(
- screen.queryByText("portal.pipelines.builder.viewDefinition"),
- ).not.toBeInTheDocument();
- expect(
- screen.queryByText("portal.pipelines.detail.clearHistory"),
- ).not.toBeInTheDocument();
- expect(
- screen.getByLabelText("portal.pipelines.builder.moreActions"),
- ).toBeInTheDocument();
- });
-
- it("opens the definition from the tray", () => {
- const handlers = renderHeader();
- fireEvent.click(
- screen.getByLabelText("portal.pipelines.builder.moreActions"),
- );
- fireEvent.click(
- screen.getByText("portal.pipelines.builder.viewDefinition"),
- );
- expect(handlers.onViewDefinition).toHaveBeenCalled();
- });
-
- it("shows no run strip until a test has been run", () => {
- renderHeader();
- expect(
- screen.queryByText(/portal.pipelines.inspector.status/),
- ).not.toBeInTheDocument();
- });
-
- it("reports a finished run and downloads the file clicked", () => {
- const handlers = renderHeader({
- runResult: {
- status: "completed",
- completedSteps: 2,
- stepCount: 2,
- outputs: [
- { fileId: "f1", fileName: "claim.pdf" },
- { fileId: "f2", fileName: null },
- ],
- },
- });
- fireEvent.click(screen.getByText("claim.pdf"));
- expect(handlers.onDownloadOutput).toHaveBeenCalledWith({
- fileId: "f1",
- fileName: "claim.pdf",
- });
- // A file the backend did not name still has to be reachable.
- expect(screen.getByText("f2")).toBeInTheDocument();
- });
-});
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx
deleted file mode 100644
index 25bfbd044f..0000000000
--- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx
+++ /dev/null
@@ -1,301 +0,0 @@
-import { useTranslation } from "react-i18next";
-import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
-import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
-import HistoryRoundedIcon from "@mui/icons-material/HistoryRounded";
-import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded";
-import ScienceOutlinedIcon from "@mui/icons-material/ScienceOutlined";
-import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
-import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded";
-import CheckCircleOutlineRoundedIcon from "@mui/icons-material/CheckCircleOutlineRounded";
-import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded";
-import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded";
-import {
- ActionIcon,
- Button,
- Checkbox,
- Dropdown,
- FilePicker,
- Input,
- Spinner,
-} from "@app/ui";
-import "@portal/components/pipelines/PipelineHeader.css";
-
-/** One file a test run produced, downloadable from the result strip. */
-export interface RunOutputFile {
- fileId: string;
- fileName: string | null;
-}
-
-/**
- * A test run's outcome. Whole-pipeline, not per-node: the backend reports one flat list of files
- * plus the step it stopped at, so there is no per-node output to attach to a node.
- */
-export interface RunResultSummary {
- status: "running" | "completed" | "failed";
- completedSteps: number;
- stepCount: number;
- error?: string | null;
- outputs?: RunOutputFile[];
-}
-
-export interface PipelineHeaderProps {
- name: string;
- onNameChange: (name: string) => void;
- enabled: boolean;
- onEnabledChange: (enabled: boolean) => void;
- /** False for a pipeline that has never been saved: it cannot yet be run, cleared or deleted. */
- isEdit: boolean;
- /** How many steps the chain has, so an empty pipeline cannot offer a test that does nothing. */
- stepCount: number;
-
- canSave: boolean;
- saving: boolean;
- onSave: () => void;
- onCancel: () => void;
- onBack: () => void;
-
- /** Run the steps as they stand against one uploaded file, without saving or delivering. */
- onTest: (file: File) => void;
- testing: boolean;
- /** Run the saved pipeline against its real input, delivering to its real destination. */
- onRun: () => void;
- running: boolean;
- onClearHistory: () => void;
- clearingHistory: boolean;
- onDelete: () => void;
-
- /** Opens the definition (JSON + cURL), which is pipeline-scoped like the rest of this row. */
- onViewDefinition: () => void;
- /** The last test run in this session, or null if there has not been one. */
- runResult: RunResultSummary | null;
- onDownloadOutput: (output: RunOutputFile) => void;
-}
-
-/**
- * The pipeline's identity and its whole-pipeline actions, at the top of the builder.
- *
- * Split in two so neither half gets lost in a single crowded row: what the pipeline *is* (name,
- * whether it is live) sits with the actions that leave the page, and what you can *do to it* sits
- * below the rule. A test run is part of building, so it lives here rather than off in a corner -
- * its progress shows on the graph's nodes and its results in the inspector.
- */
-export function PipelineHeader({
- name,
- onNameChange,
- enabled,
- onEnabledChange,
- isEdit,
- stepCount,
- canSave,
- saving,
- onSave,
- onCancel,
- onBack,
- onTest,
- testing,
- onRun,
- running,
- onClearHistory,
- clearingHistory,
- onDelete,
- onViewDefinition,
- runResult,
- onDownloadOutput,
-}: PipelineHeaderProps) {
- const { t } = useTranslation();
-
- return (
-
-
- onNameChange(e.target.value)}
- />
- {/* A checkbox, not a switch: this is a form value that takes effect on save, and a switch
- would imply it applies the moment it is flipped. No description - a second line beside
- the single-line name field leaves the row ragged. */}
- onEnabledChange(e.target.checked)}
- label={t("portal.pipelines.builder.enabled")}
- />
-
-
-
- file && onTest(file)}
- leftSection={}
- >
- {t("portal.pipelines.builder.testRun")}
-
-
- {isEdit && (
-
- }
- >
- {t("portal.pipelines.detail.run")}
-
- )}
-
- {/* Occasional things - reading the definition, wiping the processed history - kept behind a
- tray so they do not compete with running and testing, which is what this row is for. */}
-
-
-
-
-
-
-
- }
- >
- {t("portal.pipelines.builder.viewDefinition")}
-
- {isEdit && (
-
- }
- >
- {t("portal.pipelines.detail.clearHistory")}
-
- )}
-
-
-
- {isEdit && (
-
- }
- >
- {t("portal.pipelines.detail.delete")}
-
- )}
-
-
- {runResult && (
-
- )}
-
- );
-}
-
-interface RunResultStripProps {
- result: RunResultSummary;
- onDownload: (output: RunOutputFile) => void;
-}
-
-/** What the last test run did, beside the button that started it. */
-function RunResultStrip({ result, onDownload }: RunResultStripProps) {
- const { t } = useTranslation();
- const outputs = result.outputs ?? [];
-
- return (
-
-
- {/* The reason it failed, where the failure is announced - not only on the node, which the user
- has to know to click. */}
- {result.status === "failed" && result.error && (
-
- {result.error}
-
- )}
-
- {outputs.map((output) => (
-
- ))}
-
- );
-}
diff --git a/frontend/editor/src/portal/views/PipelineBuilder.css b/frontend/editor/src/portal/views/PipelineBuilder.css
index 464d3fad55..343aea95f6 100644
--- a/frontend/editor/src/portal/views/PipelineBuilder.css
+++ b/frontend/editor/src/portal/views/PipelineBuilder.css
@@ -225,3 +225,43 @@
gap: 0.5rem;
width: 100%;
}
+
+/* The graph column IS the card: the test control is its header and the graph its body, so the test
+ control reads as the graph's toolbar and - crucially - the card's top lines up with the inspector
+ beside it (a toolbar sitting *above* the card pushed the graph down out of alignment). Caps at the
+ row height and clips its rounded corners; the body scrolls inside while the header stays put. */
+.portal-builder__canvas {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ max-height: 100%;
+ overflow: hidden;
+ border: 1px solid var(--c-border-subtle);
+ border-radius: var(--radius-lg);
+ background: var(--c-surface-sunken);
+}
+
+/* The test control as the card's header, ruled off from the graph below it. */
+.portal-builder__canvas > .portal-pipeline-toolbar {
+ flex: none;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--c-border-subtle);
+}
+
+/* The graph as the card's body: it drops its own frame (the canvas provides it now) and scrolls
+ inside while the header stays put. */
+.portal-builder__canvas > .portal-graph {
+ flex: 1 1 auto;
+ min-height: 0;
+ max-height: none;
+ border: none;
+ border-radius: 0;
+ background: transparent;
+}
+
+/* Stacked (short viewport): the page scrolls, so the column must not cap or nest its own scroll. */
+@media (max-width: 60rem) {
+ .portal-builder__canvas {
+ max-height: none;
+ }
+}
diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx
index b1ff80682d..52fb5d516c 100644
--- a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx
+++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx
@@ -456,7 +456,7 @@ describe("PipelineBuilder", () => {
expect(
await screen.findByText("portal.pipelines.builder.needsSource"),
).toBeInTheDocument();
- // Still nothing chosen, so the pipeline cannot be saved.
+ // Still nothing chosen, so the pipeline cannot be created.
expect(
screen.getByText("portal.pipelines.composer.create").closest("button"),
).toBeDisabled();
@@ -601,15 +601,15 @@ describe("PipelineBuilder", () => {
target: { value: "Needs both" },
},
);
- const saveButton = () =>
+ const createButton = () =>
screen.getByText("portal.pipelines.composer.create").closest("button");
// Name only: blocked (no source, no destination).
- expect(saveButton()).toBeDisabled();
+ expect(createButton()).toBeDisabled();
// An input with a source but still no destination: blocked.
await pickInputSource("Claims intake");
- expect(saveButton()).toBeDisabled();
+ expect(createButton()).toBeDisabled();
// Both chosen: allowed, and both are sent.
await pickDestination();
@@ -782,17 +782,19 @@ describe("PipelineBuilder", () => {
).toBeInTheDocument();
});
- it("clears processed history from the header and confirms", async () => {
+ it("reprocesses the source: clears the processed record, runs, and reports", async () => {
renderBuilder("/processor/pipelines/plc-1");
await openTray();
fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory"));
+ // It forgets what was processed, then triggers a run so those files go through now.
await waitFor(() =>
expect(clearProcessedHistory).toHaveBeenCalledWith("plc-1"),
);
+ await waitFor(() => expect(triggerPipeline).toHaveBeenCalledWith("plc-1"));
expect(
- await screen.findByText("portal.pipelines.run.historyCleared"),
+ await screen.findByText("portal.pipelines.run.completed"),
).toBeInTheDocument();
});
@@ -846,6 +848,8 @@ describe("PipelineBuilder", () => {
it("deletes an existing pipeline after confirmation", async () => {
renderBuilder("/processor/pipelines/plc-1");
+ // Delete is a rare, destructive action, so it lives behind the overflow tray.
+ await openTray();
fireEvent.click(await screen.findByText("portal.pipelines.detail.delete"));
fireEvent.click(await screen.findByText("portal.pipelines.delete.confirm"));
@@ -853,6 +857,45 @@ describe("PipelineBuilder", () => {
expect(await screen.findByText("pipelines list")).toBeInTheDocument();
});
+ it("pauses a live pipeline at once, in place, and re-saves the persisted policy", async () => {
+ renderBuilder("/processor/pipelines/plc-1");
+
+ // POLICY.enabled is true, so the toggle offers to pause it.
+ fireEvent.click(await screen.findByText("portal.pipelines.builder.pause"));
+
+ await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1));
+ expect(savePipeline).toHaveBeenCalledWith(
+ expect.objectContaining({ id: "plc-1", enabled: false }),
+ );
+ // It acts in place: the builder stays open and the control now offers to activate again.
+ expect(
+ await screen.findByText("portal.pipelines.builder.activate"),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("pipelines list")).not.toBeInTheDocument();
+ });
+
+ it("creates a paused pipeline when Create paused is chosen", async () => {
+ renderBuilder("/processor/pipelines/new");
+
+ fireEvent.change(
+ await screen.findByRole("textbox", {
+ name: "portal.pipelines.composer.name",
+ }),
+ { target: { value: "Paused draft" } },
+ );
+ await addTool("Compress");
+ await pickInputSource("Claims intake");
+ await pickDestination();
+
+ fireEvent.click(screen.getByText("portal.pipelines.composer.createPaused"));
+
+ await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1));
+ expect(savePipeline).toHaveBeenCalledWith(
+ expect.objectContaining({ name: "Paused draft", enabled: false }),
+ );
+ expect(await screen.findByText("pipelines list")).toBeInTheDocument();
+ });
+
it("prompts to save or discard when leaving with unsaved edits", async () => {
renderBuilder("/processor/pipelines/new");
@@ -864,7 +907,7 @@ describe("PipelineBuilder", () => {
target: { value: "Draft" },
},
);
- fireEvent.click(screen.getByText("portal.pipelines.composer.cancel"));
+ fireEvent.click(screen.getByLabelText("portal.pipelines.builder.back"));
expect(
await screen.findByText("portal.pipelines.builder.unsavedTitle"),
@@ -928,7 +971,7 @@ describe("PipelineBuilder", () => {
await screen.findByRole("textbox", {
name: "portal.pipelines.composer.name",
});
- fireEvent.click(screen.getByText("portal.pipelines.composer.cancel"));
+ fireEvent.click(screen.getByLabelText("portal.pipelines.builder.back"));
expect(await screen.findByText("pipelines list")).toBeInTheDocument();
});
diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx
index 746fd61ab0..d5092f5c88 100644
--- a/frontend/editor/src/portal/views/PipelineBuilder.tsx
+++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx
@@ -67,7 +67,9 @@ import { useQueryClient } from "@tanstack/react-query";
import { qk } from "@portal/queries/keys";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations";
-import { PipelineHeader } from "@portal/components/pipelines/PipelineHeader";
+import { PipelineCreateHeader } from "@portal/components/pipelines/PipelineCreateHeader";
+import { PipelineEditHeader } from "@portal/components/pipelines/PipelineEditHeader";
+import { PipelineGraphToolbar } from "@portal/components/pipelines/PipelineGraphToolbar";
import { PipelineInspector } from "@portal/components/pipelines/PipelineInspector";
import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal";
import {
@@ -258,10 +260,19 @@ export function PipelineBuilder() {
const [inputAsked, setInputAsked] = useState(false);
const [outputAsked, setOutputAsked] = useState(false);
const [submitting, setSubmitting] = useState(false);
+ // Which create action is in flight, so only the button that was clicked (Create / Create paused)
+ // shows its spinner. Null in edit and while idle.
+ const [pendingCreateEnabled, setPendingCreateEnabled] = useState<
+ boolean | null
+ >(null);
const [error, setError] = useState(null);
const [seeded, setSeeded] = useState(false);
const [running, setRunning] = useState(false);
- const [clearingHistory, setClearingHistory] = useState(false);
+ // Pausing/activating an existing pipeline acts immediately (a separate save), not on the next
+ // "Save changes"; this tracks that in-flight toggle.
+ const [togglingEnabled, setTogglingEnabled] = useState(false);
+ // Clearing the processed record then running, so already-handled files go through again.
+ const [reprocessing, setReprocessing] = useState(false);
const [runResult, setRunResult] = useState(null);
const [pendingDelete, setPendingDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
@@ -587,10 +598,11 @@ export function PipelineBuilder() {
}
// Track unsaved edits: snapshot the form and compare against the state captured just after
- // seeding, so leaving the builder can prompt to save or discard.
+ // seeding, so leaving the builder can prompt to save or discard. `enabled` is deliberately left
+ // out: in edit it is toggled and persisted at once (never an unsaved edit), and in create it is
+ // chosen at submit - so it can never be the thing that makes the form dirty.
const snapshot = JSON.stringify({
name: name.trim(),
- enabled,
input,
steps: steps.map((step) => serializeToolStep(step, allTools)),
uploads: steps.map(stepRequiresUpload),
@@ -602,20 +614,46 @@ export function PipelineBuilder() {
}, [seeded, snapshot]);
const dirty = baseline.current !== null && baseline.current !== snapshot;
- // The input needs a source, and a scheduled input needs a positive interval; the pipeline
- // needs exactly one output destination.
- const inputValid =
- input.sourceId !== "" &&
- (input.triggerType !== "schedule" || Number(input.scheduleCount) > 0);
+ // Each validity condition is defined exactly once here, then consumed both by the graph (which
+ // flags each end) and by the blocker list below.
+ const sourceChosen = input.sourceId !== "";
+ const scheduleValid =
+ input.triggerType !== "schedule" || Number(input.scheduleCount) > 0;
+ const inputValid = sourceChosen && scheduleValid;
const outputValid = outputIds.length === 1;
- const canSave =
- name.trim() !== "" &&
- inputValid &&
- outputValid &&
- !hasUploadSteps &&
- !hasUnconfiguredSteps &&
- !hasIncompatibleSteps &&
- !submitting;
+
+ // The single source of truth for "can this be committed": every reason it can't be, in the order
+ // they appear down the form, so a disabled Create / Save button can say exactly what is still owed.
+ const blockers: string[] = [];
+ if (name.trim() === "")
+ blockers.push(t("portal.pipelines.builder.blocker.name"));
+ if (!sourceChosen)
+ blockers.push(t("portal.pipelines.builder.blocker.source"));
+ else if (!scheduleValid)
+ blockers.push(t("portal.pipelines.builder.blocker.schedule"));
+ if (!outputValid)
+ blockers.push(t("portal.pipelines.builder.blocker.destination"));
+ if (hasUnconfiguredSteps)
+ blockers.push(
+ t("portal.pipelines.builder.blocker.setup", {
+ tools: unconfiguredStepLabels.join(", "),
+ }),
+ );
+ if (hasUploadSteps)
+ blockers.push(
+ t("portal.pipelines.builder.blocker.upload", {
+ tools: uploadStepLabels.join(", "),
+ }),
+ );
+ if (hasIncompatibleSteps)
+ blockers.push(
+ t("portal.pipelines.builder.blocker.incompatible", {
+ tools: blockingSteps.join(", "),
+ }),
+ );
+
+ // Nothing left to fix, and not already committing.
+ const canSave = blockers.length === 0 && !submitting;
const listPath = toPortalPath(VIEW_PATHS.pipelines);
@@ -629,14 +667,14 @@ export function PipelineBuilder() {
else navigate(destination);
}
- async function save(destination: string) {
+ async function save(destination: string, enabledOverride?: boolean) {
if (!canSave) return;
setSubmitting(true);
setError(null);
const policy: Policy = {
id: policyState.data?.id ?? undefined,
name: name.trim(),
- enabled,
+ enabled: enabledOverride ?? enabled,
// The wire shape stays a list; canSave guarantees the one input has a source.
inputs: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }],
steps: steps.map((step) => serializeToolStep(step, allTools)),
@@ -652,6 +690,39 @@ export function PipelineBuilder() {
} catch (e) {
setError(errorMessage(e));
setSubmitting(false);
+ setPendingCreateEnabled(null);
+ }
+ }
+
+ // Create live or paused. The buttons disable until the pipeline is valid, so this only fires on a
+ // saveable pipeline; the flag records which button spins and whether it starts live or paused.
+ function submitCreate(enabledValue: boolean) {
+ setPendingCreateEnabled(enabledValue);
+ void save(listPath, enabledValue);
+ }
+
+ /**
+ * Pause or activate the saved pipeline now, without leaving the builder. It re-saves the
+ * persisted policy with the flag flipped - deliberately NOT the working form - so a pending chain
+ * edit is not silently committed by a pause. The dirty tracker ignores `enabled`, so this never
+ * looks like an unsaved change.
+ */
+ async function handleTogglePause() {
+ // Never run alongside a Save: both write the whole policy, and a concurrent pair would race
+ // (the pause carries the persisted steps, so it could clobber the edits Save is committing).
+ if (togglingEnabled || submitting || !policyState.data) return;
+ const next = !enabled;
+ setTogglingEnabled(true);
+ setError(null);
+ try {
+ await savePipeline({ ...policyState.data, enabled: next });
+ if (!mounted.current) return;
+ setEnabled(next);
+ await invalidatePipelines();
+ } catch (e) {
+ if (mounted.current) setError(errorMessage(e));
+ } finally {
+ if (mounted.current) setTogglingEnabled(false);
}
}
@@ -741,39 +812,45 @@ export function PipelineBuilder() {
return { tone: "info", text: t("portal.pipelines.run.empty") };
}
+ // Trigger the saved pipeline and report the outcome: what the sweep started (or why it started
+ // nothing), then each run's terminal state. Shared by Run now and the reprocess action.
+ async function reportRun(policyId: string) {
+ const outcome = await triggerPipeline(policyId);
+ const runIds = outcome.runIds;
+ if (runIds.length === 0) {
+ if (mounted.current) setRunResult(emptySweepResult(outcome));
+ return;
+ }
+ const finals = await Promise.all(runIds.map((runId) => awaitRun(runId)));
+ if (!mounted.current) return;
+ const failed = finals.find((r) => r?.status === "FAILED");
+ if (failed) {
+ setRunResult({
+ tone: "danger",
+ text: t("portal.pipelines.run.failed", { error: failed.error ?? "" }),
+ });
+ } else if (finals.some((r) => r === null)) {
+ // Gave up polling before a terminal status; the run may still finish server-side.
+ setRunResult({
+ tone: "warning",
+ text: t("portal.pipelines.run.timeout"),
+ });
+ } else if (finals.every((r) => r?.status === "COMPLETED")) {
+ setRunResult({
+ tone: "success",
+ text: t("portal.pipelines.run.completed", { count: finals.length }),
+ });
+ } else {
+ setRunResult({ tone: "info", text: t("portal.pipelines.run.running") });
+ }
+ }
+
async function handleRun() {
- if (running || !id) return;
+ if (running || reprocessing || !id) return;
setRunning(true);
setRunResult(null);
try {
- const outcome = await triggerPipeline(id);
- const runIds = outcome.runIds;
- if (runIds.length === 0) {
- if (mounted.current) setRunResult(emptySweepResult(outcome));
- return;
- }
- const finals = await Promise.all(runIds.map((runId) => awaitRun(runId)));
- if (!mounted.current) return;
- const failed = finals.find((r) => r?.status === "FAILED");
- if (failed) {
- setRunResult({
- tone: "danger",
- text: t("portal.pipelines.run.failed", { error: failed.error ?? "" }),
- });
- } else if (finals.some((r) => r === null)) {
- // Gave up polling before a terminal status; the run may still finish server-side.
- setRunResult({
- tone: "warning",
- text: t("portal.pipelines.run.timeout"),
- });
- } else if (finals.every((r) => r?.status === "COMPLETED")) {
- setRunResult({
- tone: "success",
- text: t("portal.pipelines.run.completed", { count: finals.length }),
- });
- } else {
- setRunResult({ tone: "info", text: t("portal.pipelines.run.running") });
- }
+ await reportRun(id);
} catch (e) {
if (mounted.current)
setRunResult({ tone: "danger", text: errorMessage(e) });
@@ -783,26 +860,22 @@ export function PipelineBuilder() {
}
/**
- * Forget which source files this pipeline has processed, so the next sweep
- * reprocesses everything currently in its sources (the standard retry for a
- * parked-by-failure file). Does not touch the files themselves.
+ * Reprocess everything currently in the sources: forget which files the pipeline already handled,
+ * then run at once so those files - which a normal run skips - go through now. Reports the run's
+ * outcome exactly like Run now; does not touch the files themselves.
*/
- async function handleClearHistory() {
- if (clearingHistory || !id) return;
- setClearingHistory(true);
+ async function handleReprocessAll() {
+ if (running || reprocessing || !id) return;
+ setReprocessing(true);
setRunResult(null);
try {
await clearProcessedHistory(id);
- if (mounted.current)
- setRunResult({
- tone: "success",
- text: t("portal.pipelines.run.historyCleared"),
- });
+ await reportRun(id);
} catch (e) {
if (mounted.current)
setRunResult({ tone: "danger", text: errorMessage(e) });
} finally {
- if (mounted.current) setClearingHistory(false);
+ if (mounted.current) setReprocessing(false);
}
}
@@ -1056,29 +1129,37 @@ export function PipelineBuilder() {
return (