loopable / web /src /automation /AutomationSurface.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
42.2 kB
// ---------------------------------------------------------------------------
// automation/AutomationSurface.tsx β€” the Automation surface (contract C-AUTONAV).
//
// β›” THE CONTRACT THIS FILE GUARANTEES, verbatim: this module path, a DEFAULT
// export, and NO PROPS. The shell mounts `<AutomationSurface />` and hands it
// nothing; it fetches its own data from `/api/v1/automations`. That is the whole
// interface, and it is stated here because the mount line lives in a file this
// session does not own β€” a named export would cost a cross-session round trip.
//
// "Automation replaces the Views rail" (owner) is implemented the way the wave
// scout established: the secondary rail is NOT a shell concept β€” each surface
// owns its own. So this renders a sibling of `.cg-views` built from the same
// `--lp-rail-w` tokens and the same fold behaviour, and the Views rail is
// untouched.
// ---------------------------------------------------------------------------
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
import type { AutomationOpenDetail } from "../apiContract";
import { AUTOMATION_OPEN_EVENT } from "../apiContract";
// ⭐ WAVE 26 Β· ITEM 18 / R14 + C7 β€” THE LOOPABLE LOOP MARK, CONSUMED, NEVER REDRAWN.
// The owner's reason is the mark's own geometry: *"these automations are basically loops."*
// C7 says consume the existing icon component and do not inline a new SVG path, and
// `shell/Brand.tsx`'s header says why in more detail than a rule could: the mark's one source of
// truth is generated, it has already been hand-redrawn once, and the copy silently painted LAST
// WAVE'S BRAND while a comment asserted parity. So this imports the component that POINTS at the
// generated artifact. [[loopable-nav-logo-toggle]] β€” one element paints the mark.
import { Mark } from "../shell/Brand";
import type { Automation, AutomationList } from "./automationApi";
import {
AutomationError,
cachedAutomations,
createAutomation,
listAutomations,
liveStepOf,
patchAutomation,
rememberAutomation,
} from "./automationApi";
/**
* ⭐⭐ WAVE 30 Β· T21 (owner items 4 + 5) β€” THE EDITOR STOPS SHIPPING INSIDE THE LIST.
*
* β›” THIS `lazy()` IS THE TICKET. `AutomationDetail` was a STATIC import, and it statically pulls
* `AutomationBuilder` (which in turn pulls `CondBuilder`, `PresetPlan` and `TriggerPicker`),
* `AutomationFind` and `AutomationTrigger` β€” so the whole editor was linked into the one chunk a
* person must download before the RAIL can paint a list of names. MEASURED at 88,771 B, against
* `HomePage` at 4,814 and `ConnectorsPage` at 3,551. That is the "Automation takes forever to
* load" complaint, and it had survived two waves because nothing asserted a number.
*
* β›” AND IT NEEDS ITS OWN `<Suspense>`, NOT `Shell.tsx`'s. `Lazily` already wraps this whole
* surface, but a Suspense boundary catches ANY suspending descendant β€” so without an inner
* boundary, opening an automation would suspend the boundary that owns the RAIL and blank the
* list you just clicked in. The inner one keeps the rail painted while the editor streams in.
* `fallback={null}` is deliberate and is `Lazily`'s own choice, not a shortcut: the work area is
* simply not there for the moment the chunk is in flight, which is what every other route in this
* product already does. ⚠ A skeleton here would be a NEW loading screen in the same wave that
* exists to remove one (T22).
* ⚠ Default export, checked: `Shell.tsx:53` records that `lazy()` over a NAMED export fails at
* runtime, on click, and only for that one surface β€” `AutomationDetail` exports default.
*/
const AutomationDetail = lazy(() => import("./AutomationDetail"));
/**
* The run poll's cadence (item 6). Named constants because they are a MEASURED trade-off, not a
* taste: `POLL_MAX_MS` is the longest a finished run can still look live, and it is the only
* cost the backoff has.
*/
const POLL_MIN_MS = 2500;
const POLL_MAX_MS = 8000;
/*
* ⭐ WAVE 26 Β· ITEM 18 / R14 β€” `stateTitle` STOOD HERE AND IS DELETED WITH THE DOT IT DESCRIBED.
*
* It composed the dot's tooltip ("Running now β€” <step>", "Last run failed β€” <summary>"), so it had
* exactly one reader and no reason to outlive it. Deleting the render and keeping the composer is
* how a file accumulates functions that look live and are not β€” and `noUnusedLocals` would have
* caught this one, which is not a reason to lean on it: the NEXT such function might still have a
* second caller and compile fine.
*
* β›” WHY THE TOOLTIP WAS NOT MOVED ONTO THE MARK INSTEAD. R14 is "same mark on every automation;
* no status colour". Hanging "Last run failed" off a mark that is deliberately status-free just
* moves the status channel into `title`/`aria-label`, where it is worse: invisible to the eye,
* announced to a screen reader, and contradicting the visual. The run state has a home one click
* away β€” the Builder's own "Last run succeeded / failed" lines, which R13 explicitly keeps.
*
* ⚠ `stateOf` ITSELF IS UNTOUCHED in `automationApi.ts`. `Shell.tsx:64/1095` imports it and
* `liveStepOf` calls it β€” deleting the export to tidy up this file would break a fence I do not own.
*/
/**
* The rail's second line β€” ONE fact, not two.
*
* ⚠ It carried "Next 2026-08-04 06:00 · last 2026-08-04 00:20", and the rail is `--lp-rail-w`
* wide, so it rendered as "Next 2026-08-04 06:00 Β· l…" β€” a second line whose only complete word
* was "Next". Two facts that both truncate are worth less than one that fits. The forward-looking
* one wins when a schedule exists (it is the question the rail answers: does this run itself?),
* the last run otherwise. The full picture is one click away in the editor's run history, and the
* row's `title` already says both.
*
* Caught by READING the screenshot: the DOM was correct and the CSS ellipsis was doing exactly
* its job. [[ui-invisible-to-assertions]] β€” judge the pixels even when everything is green.
*/
function railSubtitle(a: Automation): string {
/*
* ⭐ WAVE 24 (owner item 6, "Run once now is laggy / looks stuck") β€” WHILE IT IS RUNNING,
* THE ONE FACT WORTH THE LINE IS WHAT IT IS DOING.
*
* MEASURED, not guessed (`scratchpad/perf_automations.py`, mailbox D-5): the engine has been
* publishing a live step the whole time (`status.step`, `routes_automation.py:50-51`) and NO
* SURFACE HAS EVER RENDERED IT. So a `discover_instagram` run parked in its legitimate
* `BD_FILTER_WAIT = 120 s` vendor wait and a genuinely hung thread were pixel-identical: a
* pulsing blue dot and a subtitle still reciting the schedule. "Looks stuck" was not a
* performance problem β€” the poll costs 23 ms β€” it was the product declining to say.
*
* It takes the line rather than joining it, for the reason this function's note already
* gives: the rail is `--lp-rail-w` wide and two facts that both truncate are worth less than
* one that fits. The schedule is still one click away and the dot's `title` carries both.
*/
const step = liveStepOf(a);
if (step) return step;
if (a.schedule?.enabled) return a.nextRunAt ? `Next ${a.nextRunAt}` : "Scheduled";
/*
* ⭐ WAVE 26 Β· ITEM 19 / R13 β€” `Last run 2026-08-06 13:58` IS DELETED FROM THIS LINE.
*
* β›” SCOPE, because R13 draws a line that is easy to over-read: the ruling names THIS secondary
* line under the automation's name. The Builder's "Last run succeeded / failed" RESULT lines
* (`AutomationBuilder.tsx:1676`, `:1679`) are a different surface and are explicitly NOT in
* scope β€” they are the answer to "Run once now", so deleting them would leave a button with no
* outcome.
*
* "Manual only" STAYS, and the distinction is the ruling's own: a dated run record is a
* changing FACT ABOUT THE PAST, while "this one has no schedule" is a stable property of the
* automation β€” the same question `Next …`/`Scheduled` answers for its siblings. Dropping it too
* would leave a manual automation with a blank second line and nothing saying why.
*/
return "Manual only";
}
/*
* β›” W23-W5's LISTENER IS AT MODULE SCOPE, AND THAT IS THE POINT β€” not a stylistic choice.
*
* The frame's click-through sets `window.location.hash = "#/automation"` and signals on the
* VERY NEXT LINE (Shell.tsx:1647-1650, and its comment is right that the order matters). But a
* hash write does not mount anything synchronously: `hashchange` is delivered as a task, the
* router state then updates, React renders, and only THEN does a component effect subscribe.
* A listener registered in `useEffect` therefore misses every click that arrives from another
* page β€” which is every click, since a reader looking at Alerts is by definition not already
* on this surface. The event would dispatch into nothing and every gate would stay green:
* exactly the wave-20 item-25 failure the contract's own note describes, reproduced one layer
* down.
*
* This module is imported statically by the shell, so this listener exists from app start. It
* LATCHES the request; the component consumes the latch when it mounts and hears live events
* while it is mounted. A request nobody claims is dropped on the next one β€” the latch is a
* one-slot mailbox, never a queue.
*/
let pendingOpen: AutomationOpenDetail | null = null;
const openSubscribers = new Set<(detail: AutomationOpenDetail) => void>();
if (typeof window !== "undefined") {
window.addEventListener(AUTOMATION_OPEN_EVENT, (event) => {
const detail = (event as CustomEvent<AutomationOpenDetail>).detail;
if (!detail?.autoId) return;
if (openSubscribers.size) {
for (const notify of openSubscribers) notify(detail);
return;
}
pendingOpen = detail;
});
}
/*
* β›” THE `AUTOMATION_CREATE_EVENT` LISTENER STOOD HERE AND IS DELETED WITH ITS SIGNALLER
* (wave 25 item 5a, ruling R8) β€” latch, subscriber set and all.
*
* Wave 24 built it to close the opposite defect: the event was declared, signalled from
* `Shell.tsx`, and consumed NOWHERE, so all three "Automated database" doors navigated to
* `#/automation` and then did nothing, in production, with every gate green. R8 now deletes those
* three doors β€” creating a database and pointing an automation at it are two acts β€” and they were
* the event's only signaller.
*
* β›” SO THIS SIDE GOES TOO, IN THE SAME CHANGE. A listener with no signaller is the same defect
* read from the other end: it compiles, it costs nothing at runtime, and it reads to the next
* person as a live channel. The rule that catches both is now DERIVED rather than remembered β€”
* `verify_automation_ui.py` enumerates every `AUTOMATION_*_EVENT` in `apiContract.ts` and demands
* a signal site AND a listener site for each.
*
* ⚠ NOTHING ABOUT CREATING AN AUTOMATION IS LOST. `createAndOpen` is untouched and still has two
* doors, both on this surface: the rail's button and the empty state's. What is gone is the claim
* that a database can be created BY asking for an automation.
*/
/**
* A default name for a brand-new automation, and it has to be one the server ACCEPTS.
*
* MEASURED against the live route rather than assumed (`scratchpad/probe_names.py`): a create
* with no name is a 400 (`name the automation`), and duplicate names are ALLOWED. So the name
* cannot be omitted, and a fixed literal would stack "New automation" three deep in a rail that
* sorts by name with nothing to tell the rows apart. Numbering from the existing list is a
* client-side convenience over a server that does not care: if two tabs race, the loser gets a
* duplicate name, which is legal, visible and renameable β€” never an error the user has to read.
*/
function nextAutomationName(existing: Automation[]): string {
const taken = new Set(existing.map((a) => (a.name || "").trim().toLowerCase()));
for (let n = existing.length + 1; ; n += 1) {
const candidate = `Automation ${n}`;
if (!taken.has(candidate.toLowerCase())) return candidate;
}
}
export default function AutomationSurface() {
/* ⭐ T22 β€” SEEDED FROM THE CLIENT MEMO, so a revisit inside the freshness window never passes
through `data === null` and therefore never paints the skeleton. The lazy initialiser runs
once, on mount, BEFORE the first paint β€” `useState(cachedAutomations())` would call it on
every render instead, which is the same value at needless cost. A cold start still returns
null and still gets the skeleton: it is made rare, not removed (see `automationApi.ts`). */
const [data, setData] = useState<AutomationList | null>(() => cachedAutomations());
const [error, setError] = useState("");
const [activeId, setActiveId] = useState<string>("");
const [railShut, setRailShut] = useState(false);
const [busy, setBusy] = useState("");
/**
* ⭐ WAVE 24 / C-CREATE β€” a create is now a ROUND TRIP, not a local wizard, so the door has to
* say it is busy. Without this a second click while the POST is in flight makes a second
* automation, and the server allows duplicate names, so the user gets two rows that look
* identical and no error to explain either of them.
*/
const [creatingNow, setCreatingNow] = useState(false);
/** An open request waiting for the list to arrive (see the resolver below). */
const [openRequest, setOpenRequest] = useState<AutomationOpenDetail | null>(null);
/* β›” `createRequest` LEFT WITH THE EVENT THAT SET IT (wave 25, R8) β€” see the tombstone above. */
const listRef = useRef<HTMLDivElement | null>(null);
/**
* β›” C14 LEG 2 β€” THE ID THE USER LAST ASKED FOR, and it is a ref because it has to be
* written SYNCHRONOUSLY, inside the click, before any promise that was already in flight
* can resolve. State would not do: a resolution racing React's commit would read the
* previous value, which is the exact window this guard exists to close.
*/
const wantedId = useRef("");
/**
* ⭐ THE ONLY PLACE THE RAIL'S SELECTION MOVES ON PURPOSE. Every deliberate change of
* automation β€” a rail click, a create, a delete β€” goes through here, so "what the user
* asked for" and "what is on screen" are written in one statement and cannot drift apart.
* A resolution that wants to steer the rail compares itself against `wantedId` instead.
*/
const select = useCallback((id: string, _stage = "") => {
wantedId.current = id;
setActiveId(id);
}, []);
/**
* β›” C14 LEG 3 (the paint half) β€” A STALE READ MAY NOT REPAINT.
*
* Every call takes the next generation; only the newest one is allowed to `setData`. The
* defect this closes is not cosmetic: a poll issued BEFORE a delete resolves AFTER it, and
* a list that still contains the deleted automation puts a row back in the rail that the
* user has just watched disappear. Same for a toggle, and same for a create.
*
* ⚠ THE RETURN VALUE IS NOT GUARDED, deliberately. The caller asked a question and gets
* its own answer β€” coupling the two would mean a create whose `load()` was overtaken by a
* poll silently failed to select the automation it had just made.
*/
const gen = useRef(0);
const load = useCallback(async (abort?: AbortSignal) => {
const mine = ++gen.current;
try {
const next = await listAutomations(abort);
if (mine === gen.current) {
setData(next);
setError("");
}
return next;
} catch (e) {
if ((e as Error)?.name === "AbortError") return null;
if (mine === gen.current)
setError(
e instanceof AutomationError
? e.message
: "The automation service did not answer."
);
return null;
}
}, []);
/**
* ⭐⭐ WAVE 31 Β· T31 β€” one freshly-written automation replaces its own row, with no round trip.
*
* ⚠ IT TAKES THE GENERATION, and that is not decoration. `load`'s guard exists so a stale READ
* cannot repaint; a WRITE response is by definition newer than any read already in flight, so it
* must win the same race rather than sit outside it. Without the bump, a `GET /automations`
* issued before the save could resolve after the merge and paint the pre-save row back β€” the
* exact ghost `gen` was introduced for, arriving through the new door.
*
* ⚠ Returns FALSE when there is nothing to merge into (a cold surface has no memo), and the
* caller then does a real load. Painting a one-row list would be worse than a round trip.
*/
const mergeSaved = useCallback((fresh: Automation) => {
const next = rememberAutomation(fresh);
if (!next) return false;
gen.current += 1;
setData(next);
setError("");
return true;
}, []);
useEffect(() => {
const ac = new AbortController();
void load(ac.signal);
return () => ac.abort();
}, [load]);
/**
* ⭐⭐ WAVE 24 / C-CREATE (a) β€” CREATE AND OPEN. This REPLACED the three-step wizard.
*
* R6 is what makes it possible: `plain` is a real kind with no machine graph nodes, and it is
* what `POST /automations` stores when the body names none. So "new automation" stopped being
* a question ("which of three kinds?" β€” two of which can no longer be created at all) and
* became what it says: a new automation, open, on its trigger picker. Choosing a SOURCE is now
* picking the `ig_profile_match` trigger, which is where that choice belongs.
*
* β›” THE GUARD IS A REF, not the `creatingNow` state, and this file already carries the scar
* that explains why (C14 leg 2): a state read inside a click closure is the value from the
* last render, so two fast clicks both see `false` and both POST. Duplicate names are legal
* server-side, so the user would get two identical rows and no error. The ref is written
* synchronously, inside the click, before anything can await.
*/
const creatingRef = useRef(false);
const createAndOpen = useCallback(async () => {
if (creatingRef.current) return;
creatingRef.current = true;
setCreatingNow(true);
try {
const res = await createAutomation({
name: nextAutomationName(data?.automations || []),
});
const id = res?.automation?.id || "";
// The create flow is the one caller allowed to name a different id (C14 leg 2) β€” the
// automation did not exist when the click happened, so there is nothing to race with.
const next = await load();
if (id && next) select(id);
} catch (e) {
setError(
e instanceof AutomationError ? e.message : "The automation was not created."
);
} finally {
creatingRef.current = false;
setCreatingNow(false);
}
}, [data, load, select]);
// A run is a background thread on the server, so the surface has to ASK whether it
// finished. Polling only while something is actually running keeps an idle surface
// silent β€” a fixed interval would be a request every few seconds forever, for a page
// whose contents change a handful of times a day.
//
// β›” C14 LEG 3 (the abort half). It used to call `load()` bare β€” no signal, nothing to
// cancel β€” so a request the interval had already issued kept going after the effect that
// owned it was gone. One controller per effect run, aborted with the interval, means a poll
// cannot outlive the condition that justified it. The generation guard inside `load` covers
// the rest: a response that survives the abort still cannot repaint over a newer one.
//
// ⭐ WAVE 24 (item 6, C-PERF) β€” THE INTERVAL BECAME A BACKOFF, and the measurement is why it is
// a SMALL change rather than the big one the contract's hypothesis 2 asked for.
//
// MEASURED (`scratchpad/perf_automations.py`, mailbox D-5): this poll costs ~23 ms and 24 KB
// for a ten-automation tenant, and the engine work the hypothesis blamed β€” a `graph()` rebuild
// per automation β€” is **41 Β΅s each**, i.e. under 4% of the request. The payload was never the
// problem, so nothing here gets a cheaper endpoint.
//
// What the numbers DO indict is the aggregate: a discovery run legitimately blocks up to
// `BD_FILTER_WAIT` = 120 s, and two independent 2.5 s polls across this file and the detail
// spend ~96 requests and ~2.3 MB over that window β€” in the same single process the run itself
// is a thread in. So the delay grows 2.5 β†’ 5 β†’ 8 s and stops there.
//
// ⚠ NOT "to learn nothing", and the distinction is the whole justification. That WAS true when
// this file rendered no live step: the poll's only observable effect was a dot that had already
// been pulsing for two minutes. It stopped being true in this same change β€” `liveStepOf` now
// paints `status.step`, so a poll carries the one fact worth having. The backoff is therefore
// NOT "stop asking a pointless question"; it is FEWER ROUND TRIPS FOR THE SAME INFORMATION, on
// a step text that changes every few seconds at most, not every 2.5.
// The cap is deliberately low: it bounds how long a FINISHED run can still look live, which is
// the only thing a backoff can make worse, and 8 s of that is worth ~β…” fewer requests.
//
// β›” THE KEY IS THE RUNNING SET, NOT A BOOLEAN, and that is what makes the reset correct: the
// effect re-runs β€” and the delay drops back to 2.5 s β€” the moment a run starts or finishes, so
// a user who clicks Run now gets the fast cadence again instead of inheriting the tail of the
// previous run's backoff. A bare `anyRunning` boolean cannot see the second run start.
// A step text changing does NOT re-key it, so the interval never thrashes.
//
// C14 LEG 3 (the abort half) is unchanged and still load-bearing: one controller per effect
// run, aborted with the timer, so a poll cannot outlive the condition that justified it. The
// generation guard inside `load` covers the rest.
const runningKey = (data?.automations || [])
.filter((a) => a.running)
.map((a) => a.id)
.sort()
.join(",");
useEffect(() => {
if (!runningKey) return undefined;
const ac = new AbortController();
let delay = POLL_MIN_MS;
let timer = 0;
const tick = () => {
void load(ac.signal);
delay = Math.min(delay * 2, POLL_MAX_MS);
timer = window.setTimeout(tick, delay);
};
timer = window.setTimeout(tick, delay);
return () => {
window.clearTimeout(timer);
ac.abort();
};
}, [runningKey, load]);
// ── W23-W5, the surface's half: hear the request, then answer it when we CAN ──────────
useEffect(() => {
const notify = (detail: AutomationOpenDetail) => setOpenRequest(detail);
openSubscribers.add(notify);
if (pendingOpen) {
const latched = pendingOpen;
pendingOpen = null;
setOpenRequest(latched);
}
return () => {
openSubscribers.delete(notify);
};
}, []);
/* β›” THE C-CREATE(b) SUBSCRIBE EFFECT AND ITS RESOLVER STOOD HERE (wave 25, R8). They heard the
create event and, once the list had arrived, called `createAndOpen` β€” the "wait for the list
or every automation is named Automation 1" note lives on in `nextAutomationName`, which is
still numbered off `existing`. With no signaller there is nothing to hear. */
/**
* ⚠ THE REQUEST OUTLIVES THE FETCH, and it has to. The reader clicks a notification from
* another page, so this surface is mounting WITH AN EMPTY LIST β€” "select it if it is in the
* list" would drop every real click and keep only the one case where the reader was already
* here. So the request is held until `data` exists, and only then answered.
*
* An automation the reader can no longer open does NOTHING (the contract's own words): the
* request is cleared either way, so a stale id cannot sit here re-firing against every
* subsequent list.
*/
useEffect(() => {
if (!openRequest || !data) return;
const found = (data.automations || []).some((a) => a.id === openRequest.autoId);
if (found) {
select(openRequest.autoId, openRequest.stageId || "");
}
setOpenRequest(null);
}, [openRequest, data, select]);
const items = data?.automations || [];
const active = items.find((a) => a.id === activeId) || null;
/**
* ⭐⭐ WAVE 32 Β· T43 (owner item 2) β€” TURNING AN AUTOMATION ON OR OFF STOPS TAKING FOREVER.
*
* Owner: turning an automation on or off "takes forever". β›” THE TICKET'S HYPOTHESIS WAS THE
* SERVER AND IT IS NOT β€” MEASURED. `engine.patch` for a schedule-only body takes ONE
* `automations` bucket read, ZERO `user_tables` reads and ~1 ms against a 2.8 MB fixture, and
* `GET /automations` is one read too since W30-T12. The cost was never the document shape
* (D-179/D-175's family): it was that this handler paid **two sequential round trips** for one
* click, and painted nothing until BOTH had landed. The second one re-downloaded the entire
* rail payload β€” 88,771 B measured, see the note at the top of this file β€” to learn one boolean
* the first response had already returned.
*
* β›” AND THE FIX ALREADY EXISTED IN THIS FILE, POINTED AT A DIFFERENT BUTTON. `mergeSaved` is
* W31-T31, built for the detail pane's saves, with its own note about the write response being
* newer than any read in flight. The rail's own switch never got it β€” a fix applied to one of
* two twins, which is this wave's recurring shape.
*
* Three states, in order, and the middle one is what the owner actually asked for:
* 1. FLIP IT NOW. The switch is the user's instruction, not a question; painting it after a
* network round trip is what "takes forever" describes even when the round trip is fast.
* 2. adopt the server's own answer when it lands, which carries the derived fields an
* optimistic row cannot invent (`nextRunAt`, the status line).
* 3. put the OLD row back if the write failed, beside the error β€” an optimistic switch that
* stays on after a refusal is a lie, and a worse one than a slow switch.
*
* ⚠ `mergeSaved` TAKES THE GENERATION, which is what stops [[refetch-eats-its-own-write]] here:
* a `GET /automations` issued before the click can still resolve after it, and without the bump
* it would paint the pre-toggle row back over both the optimistic flip and the confirmation.
* ⚠ `busy` is no longer what makes the switch look right β€” it stays only to keep a second click
* from racing the first.
*/
const toggleEnabled = async (a: Automation) => {
const flipped: Automation = {
...a,
schedule: { ...a.schedule, enabled: !a.schedule.enabled },
};
setBusy(a.id);
const painted = mergeSaved(flipped);
try {
const { automation: fresh } = await patchAutomation(a.id, {
schedule: { cron: a.schedule.cron, enabled: !a.schedule.enabled },
});
// A cold surface has no memo to merge into (`mergeSaved` answers false), and a one-row
// repaint would be worse than a round trip β€” the same rule `onSaved` follows.
if (!fresh || !mergeSaved(fresh)) await load();
} catch (e) {
if (painted) mergeSaved(a);
setError(e instanceof AutomationError ? e.message : "That change was not saved.");
} finally {
setBusy("");
}
};
const onRailKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
const rows = Array.from(
listRef.current?.querySelectorAll<HTMLButtonElement>(".auto-row-main") || []
);
if (!rows.length) return;
event.preventDefault();
const at = rows.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === "ArrowDown" ? (at + 1) % rows.length
: (at - 1 + rows.length) % rows.length;
rows[next < 0 ? 0 : next]?.focus();
};
return (
<div className="auto-surface">
<aside
className={"auto-rail" + (railShut ? " is-collapsed" : "")}
aria-label="Automations"
>
{/* The same three-bars fold control the Views rail carries β€” the two rails are
one idea, so they must not fold with two different affordances. */}
<div className="auto-rail-top">
<button
type="button"
className="cg-rail-toggle"
aria-label={railShut ? "Expand automations" : "Minimize automations"}
aria-expanded={!railShut}
title={railShut ? "Expand automations" : "Minimize automations"}
onClick={() => setRailShut((v) => !v)}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M2.5 4.4h11M2.5 8h11M2.5 11.6h11"
stroke="currentColor"
strokeWidth="1.35"
strokeLinecap="round"
/>
</svg>
</button>
</div>
<div className="auto-create">
<button
type="button"
className="auto-create-btn"
disabled={creatingNow}
onClick={() => void createAndOpen()}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M8 3.4v9.2M3.4 8h9.2"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
New automation
</button>
</div>
<div className="auto-list" ref={listRef} onKeyDown={onRailKey}>
{items.map((a) => {
return (
<div
key={a.id}
className={"auto-row" + (a.id === activeId ? " is-active" : "")}
>
<button
type="button"
className="auto-row-main"
onClick={() => select(a.id)}
>
{/* ⭐ ITEM 18 / R14 β€” THE LOOP MARK, WHERE THE STATUS DOT WAS. Same mark on
every row, no status colour: the owner's point is that an automation IS a
loop, not that this one is green. The `.auto-loopmark` wrapper is what sizes
it β€” `.lp-mark` is shell-owned CSS and outside this session's region, so the
box is mine and the mark is theirs. */}
<span className="auto-loopmark">
<Mark size={15} />
</span>
<span className="auto-row-text">
<span className="auto-row-name">{a.name}</span>
<span className="auto-row-desc">{railSubtitle(a)}</span>
</span>
</button>
<button
type="button"
className={"auto-row-switch" + (a.schedule?.enabled ? " is-on" : "")}
disabled={busy === a.id}
aria-pressed={!!a.schedule?.enabled}
title={
a.schedule?.enabled
? `Scheduled: ${a.schedule.cron}. Click to pause.`
: "Not scheduled. Click to enable."
}
onClick={() => void toggleEnabled(a)}
>
<span className="auto-row-switch-knob" />
</button>
</div>
);
})}
{/* ONE LINE (R13). It used to describe the kinds β€” "one reads a public web page…
the other fills an automation column…" β€” which was two sentences, wrong by the
time a third kind shipped, and printed inside a `--lp-rail-w` column. What the
kinds are belongs to the create form, which is one click away and lists all of
them from the server. */}
{/* ⭐ WAVE 29 (W29-T01) β€” A LOADING RAIL IS NOT AN EMPTY ONE. `data` is null until the
first `listAutomations()` resolves, and this branch read only `items.length`, so
every visit to Automation asserted "No automations yet." to a person who has
several β€” then replaced it with their list. Half of owner item 5's "takes a while
to appear" is that sentence: the wait is real, but being told you own nothing is
what makes it read as broken rather than slow. `data === null` is the one state
that means NOBODY HAS LOOKED, and it gets skeleton rows, not a claim. */}
{data === null && !error ? (
<div className="auto-rail-loading" aria-hidden="true">
<span className="auto-rail-skel" />
<span className="auto-rail-skel" />
<span className="auto-rail-skel" />
</div>
) : null}
{data !== null && !items.length && !error ? (
<p className="auto-rail-empty">No automations yet.</p>
) : null}
</div>
</aside>
<section className="auto-main">
{error ? (
<div className="auto-banner is-error" role="alert">
{error}
</div>
) : null}
{data && !data.storeAvailable ? (
<div className="auto-banner is-warn" role="status">
The tenant store is unavailable, so nothing can be saved right now.
</div>
) : null}
{/*
β›” THE `creating` BRANCH IS GONE (wave 24, C-CREATE a / owner items 3 + 4).
`AutomationCreate` used to render here β€” a three-step wizard whose first step asked
which DATABASE to write into (an existing one, a new one, or one the automation itself
would create) and whose second asked which of three KINDS.
R6 retires that question at the root: two of the three kinds can no longer be created
at all, and the third is now reached by picking a trigger. A new automation is created
the moment it is asked for (`createAndOpen`) and opens on its own Builder, so there is
no intermediate face left to render and no `creating` state to hold.
⭐ WAVE 25 (R8) retires the LAST of that vocabulary: the third answer was the
"automated database", and it is gone from every door in the product. This button is now
one of exactly two ways to make an automation, and both are on this surface.
The import went with it β€” it is what would break `npx tsc -b` for the WHOLE client the
moment session B deletes the file, which is why this deletion is sequenced first.
*/}
{/* T21: the editor's own Suspense boundary β€” see the `lazy()` above for why it cannot be
`Shell.tsx`'s. `fallback={null}`, matching `Lazily`. */}
{active ? (
<Suspense fallback={null}>
<AutomationDetail
key={active.id}
automation={active}
/* β›” `kinds={data?.kinds || []}` LEFT HERE WITH THE PROP IT FED (wave 25, D-57) β€”
both halves in one change, because either alone is a `tsc` error. */
cronPresets={data?.cronPresets || []}
paidReady={!!data?.paidReady}
discover={data?.discover}
// The trigger vocabulary (C3). Forwarded as-is β€” absent stays absent, so the
// trigger face can tell "the server offered nothing" from "the server offered
// an empty list" rather than collapsing both into a picker with no options.
triggers={data?.triggers}
// C4's action menu + the builder's ceilings. Forwarded as-is for the same reason
// `triggers` is: absent must stay absent, so the builder can tell "the server
// offered nothing" from "the server offered an empty list".
catalog={data?.actionsCatalog}
vocab={data?.flow}
// `?? null` and never `|| {enabled:false}`: an absent tick bit is "the server did
// not say", which Step 1 prints as its own sentence. Defaulting it here would
// turn a missing field into a claim about production (C6 amendment #1).
tick={data?.tick ?? null}
/*
* ⭐ W24-W1 (item 6) β€” WHAT THIS RUN IS DOING, on the one surface that survives in
* BOTH views. REQUIRED on the far side deliberately: an optional prop that nobody
* passes degrades to "the feature does not exist", which is indistinguishable from
* "it was never built" β€” and this whole item exists because a live step text rode
* the wire for three waves with nothing rendering it.
* `liveStepOf` returns the step ONLY while the automation is running; a stale step
* from a finished run is a worse answer than none.
*/
liveStep={liveStepOf(active)}
/*
* β›” C14 LEG 2 β€” THE STALE-ID WRITE-BACK, and this is the ghost's second cause.
*
* The detail calls `onSaved(automation.id)` from six places (save, the trigger
* picker, a schedule change, a node switch, a card move, a run). Each closure
* captures the automation it was mounted for, so a save that resolves AFTER the
* user has clicked a different row used to call `setActiveId(the OLD id)` β€” the
* rail jumped back, the detail remounted, and what the user saw was the previous
* automation reappearing over the one they had just opened. It looked like a
* rendering bug; it was a resolution steering the selection.
*
* A resolution may no longer steer anything. It reloads the list β€” that part was
* always right β€” and it re-asserts the selection ONLY when its id is still the one
* the user asked for, which makes the write a no-op in the good case and nothing
* at all in the bad one.
*/
onSaved={async (id, fresh) => {
// ⭐⭐ WAVE 31 Β· T31 β€” CORRECT THE ONE ROW WE WERE JUST TOLD ABOUT.
// `patchAutomation` and `toggleNode` return the updated definition; re-reading the
// whole list to learn it is the second of the two round trips "Saving…" used to
// span. `mergeSaved` keeps the memo and the component in step (see
// `automationApi.rememberAutomation`) and takes the generation, because a write
// response is newer than any read still in flight β€” the same rule `load` follows,
// pointed the other way.
if (fresh && mergeSaved(fresh)) {
if (id && id === wantedId.current) select(id);
return;
}
const next = await load();
if (id && next && id === wantedId.current) select(id);
}}
onDeleted={async () => {
select("");
await load();
}}
/>
</Suspense>
) : (
/*
* THE EMPTY STATE IS ONE LINE AND A BUTTON (owner ruling R13 β€” "never
* over-explain", now a DESIGN.md law).
*
* It was a heading, a three-sentence paragraph and a 130-word bulleted list
* describing all three kinds. Every word of it was true and none of it was READ:
* an empty state is passed through, not studied, and the person looking at it has
* already decided to make an automation. The kinds are described where the choice
* is actually made β€” the create form lists them FROM THE SERVER, so that copy also
* cannot go stale the way this list had (it described two kinds after a third
* shipped).
*
* The button is here rather than only in the rail because this pane is where the
* eye is; a create affordance the user has to go find is the same defect as the
* paragraph, spent differently.
*/
<div className="autob-empty">
{/*
β›” NO TITLE HERE ANY MORE (C13, owner item 2). This pane carried an `h1`
reading "Automations" at 20px/600 β€” a THIRD title treatment on a page that
also had the header's editable 16px/700 input, against every database page's
single 16px/650 `shell-db-name`. The shell now wraps this branch in the same
`shell-db-frame` + `DbHead` a database gets (wiring W23-W1), so the page's name
is drawn once, by the one component that draws every other page's name. A
stand-in restyled to match would have been a second copy of the same fact,
free to drift the day the header moves.
*/}
<p className="autob-empty-line">
A job this workspace runs for you, on demand or on a schedule.
</p>
<button
type="button"
className="auto-btn is-primary"
disabled={creatingNow}
onClick={() => void createAndOpen()}
>
New automation
</button>
</div>
)}
</section>
</div>
);
}