| // --------------------------------------------------------------------------- | |
| // 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> | |
| ); | |
| } | |