// ---------------------------------------------------------------------------
// 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 `` 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 ``, 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 — ", "Last run failed — "), 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).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(() => cachedAutomations());
const [error, setError] = useState("");
const [activeId, setActiveId] = useState("");
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(null);
/* ⛔ `createRequest` LEFT WITH THE EVENT THAT SET IT (wave 25, R8) — see the tombstone above. */
const listRef = useRef(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) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
const rows = Array.from(
listRef.current?.querySelectorAll(".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 (
{error ? (
{error}
) : null}
{data && !data.storeAvailable ? (
The tenant store is unavailable, so nothing can be saved right now.
) : 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 ? (
{
// ⭐⭐ 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();
}}
/>
) : (
/*
* 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.
*/
{/*
⛔ 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.
*/}
A job this workspace runs for you, on demand or on a schedule.