Automations and webhooks for the AT Protocol airglow.run

fix(automations): restore a blank-form autosave draft from the plain New link after a lexicon is picked #62

Closed tyb.there-hugo.dev
Closed opened by tyb.there-hugo.dev

Problem #

On /automations/new, the autosave key includes the live ?lexicon= param. Picking a lexicon rewrites the URL (history.replaceState in fetchFields), and the draft moves to the ?lexicon=X key. Every entry point other than a reload ("New" in the header, the home CTA) goes to plain /automations/new, which reads a different key. So after a closed tab, or a crash without session restore, the user's draft is not restored and stays invisible until it expires after 7 days.

Evidence #

  • app/islands/AutomationForm.tsx: autosaveContext ([viewerHandle, initial ?? null, initial ? null : getInitialParam("lexicon")]) and lastAutosaveContextRef in writeAutosave; fetchFields rewrites ?lexicon= (about lines 869-875).
  • app/components/Layout/Header/index.tsx:39 and app/routes/index.tsx:50 link to plain /automations/new.
  • Tests in app/islands/AutomationForm.test.tsx: "keeps a blank form's draft to the ?lexicon= it was started from" (a draft under ?lexicon= does not restore on the plain path) and "moves a blank form's draft along when picking a lexicon rewrites ?lexicon=". Commit 5e1491f (#33).

Expected #

A blank-form draft comes back on the next visit to /automations/new, whichever way the user arrives. A link that asks for a specific, different lexicon does not silently load another lexicon's draft.

Acceptance criteria #

Out of scope #

The edit-page key (separate issue), cross-tab sync, and the scope-upgrade stash.

Optional, low, can be folded in: keep header values that are exactly a lone {{secret:NAME}} reference when blanking headers in app/islands/step-drafts/header-values.ts:33-38.

Queued: the loop will work on this version of the issue.

loop:queued cid=bafyreig4eftjnz6c7ssrc3st3jtogstvdgx2hl2iw3gk7k6d4336knpjyq

Working on this, attempt 1.

loop:claim run=contribute-20261028-165048-3myumrbtbii22 branch=loop/3myumrbtbii22 attempt=1

fix(automations): restore a blank-form autosave draft from the plain New link after a lexicon is picked

The blank automation form's autosave key no longer includes ?lexicon=. It now depends only on the viewer, the seed (none for a blank form) and the path. Picking a lexicon rewrites the URL, and every "New" link goes to plain /automations/new, so the old key left the draft where no entry point would find it. When a link asks for a different lexicon (?lexicon=B with a draft for A), the draft is not applied. The page shows a notice instead: "You have an unsaved draft for A, autosaved on ...", with Restore that draft and Discard that draft buttons. While the form is untouched, the draft stays in storage for a later plain visit.

What changed #

  • app/islands/AutomationForm.tsx:
    • autosaveContext is now [viewerHandle, initial]. The old-key cleanup (lastAutosaveContextRef) is gone because the key no longer moves. As the issue says, old drafts are not migrated.
    • A draft whose lexicon differs from a non-empty ?lexicon= is held back:
      • Autosave neither clears it nor writes over it while the form is untouched.
      • The first change writes this form's draft over it. The notice still offers the held-back draft from memory, and restoring then asks for confirmation first.
      • Restoring applies the draft, which rewrites ?lexicon= to the draft's lexicon, and shows the usual "We restored..." notice.
      • Discarding asks for confirmation and clears the draft.
      • In both cases focus moves to the Name field.
    • The code that put the pristine state back into the form, which discardAutosave used, is moved into a shared applyDraft helper. The new restore uses it too.
  • The optional point from the issue is included: withoutHeaderValues (app/islands/step-drafts/header-values.ts) keeps a header value when it is nothing but one {{secret:NAME}} reference, with or without inner padding. Mixed values like Bearer {{secret:x}}, and anything else, are still blanked.

Verification #

  • pnpm run check passes.
  • pnpm run test passes: 178 files, 2742 tests.
  • New tests in app/islands/AutomationForm.test.tsx, which replace "keeps a blank form's draft to the ?lexicon= it was started from" and "moves a blank form's draft along...":
    • plain path → type a name → pick a lexicon → autosave → remount on the plain path: the draft is restored. A successful create then leaves no draft, and the next blank form starts clean.
    • a draft for A is not applied on ?lexicon=B: you see the notice and it stays in storage. It is applied on the plain path and on ?lexicon=A.
    • restoring a held-back draft on request: URL, notice and focus are right, and Discard goes back to B.
    • discarding a held-back draft: it is cleared and focus moves to Name.
    • changing the B form autosaves over the held-back draft. Restore asks for confirmation and keeps the form when the answer is no.
  • New test in header-values.test.ts for lone secret references.
  • Keyboard: I did not try this in a browser because there is no dev server or PDS in this environment. Both controls are native type="button" buttons, so they are in the tab order. Their names ("Restore that draft", "Discard that draft") differ from the restored notice's "Discard draft". After either acts, focus goes to #sub-name, and the tests assert that.

Acceptance criteria #

Decisions you can reverse #

  • On ?lexicon=B there is still only one draft slot. Once the user changes the B form, its draft replaces A's in storage, so A survives only in the open page's memory. Keeping both would need per-lexicon slots, which this issue moved away from.
  • The held-back notice has both Restore and Discard. A notice that only says the draft is kept for later would be smaller.
  • I folded in the optional lone {{secret:NAME}} header change as its own commit, so it can be dropped on its own.

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/62

loop:pushed run=contribute-20261028-165048-62 sha=6fd23f3eeb7a1d5e24486c7313ef16039ee4a662

Verdict: merge. The blank form's autosave key now depends only on the viewer, the seed and the path, not on ?lexicon=. A draft for lexicon A is held back on a ?lexicon=B link: the page shows a notice, and the draft stays in storage until the form is changed. Every acceptance criterion is met and has a test, and pnpm run check and pnpm run test both pass (178 files, 2742 tests).

What I checked and how:

  • Key: autosaveContext is [viewerHandle, initial ?? null], and form-autosave.ts adds location.pathname to it. lastAutosaveContextRef is gone from all four places that used it: the write, discard, submit-success and old-key cleanup paths. No migration, as the issue asks.
  • Entry points: the header's "New" link and the home CTA both go to the plain path. The lexicon page links to ?lexicon=NSID. Duplicates use ?from=&rkey=, where initial is set and linkLexicon is empty, so they are never held back.
  • Holding a draft back: the draft is held only when ?lexicon= is non-empty and differs from draft.lexicon. A draft with no lexicon counts as different, which matches the criterion as written.
  • Autosave with a held draft: an untouched form neither clears nor overwrites the held draft. The first real change writes over it, as the notice says. Discard only clears storage while the held draft is still there. A successful create still clears the draft.
  • Extra flows in a throwaway test (now reverted), mounting the form again at each step as the app's own links would:
    1. Plain path, pick A, then open ?lexicon=B: the draft is held. Restore it, then reload at the rewritten ?lexicon=A: it is restored, with no held-back notice.
    2. Leave the ?lexicon=B form untouched, then go back via plain "New": A is restored.
    3. Change the B form, then go to plain: B's draft is restored. Then open ?lexicon=A: B's draft is held back, with the notice.
    4. Type only a name on the plain path, then open ?lexicon=B: the notice says the draft has no lexicon. Restoring it removes ?lexicon= and moves focus to Name.
  • Keyboard and focus: "Restore that draft" and "Discard that draft" are native type="button" buttons, so they work from the keyboard. Their names differ from the restored notice's "Discard draft". After either acts, focus goes to #sub-name, never to <body>.
  • Optional header change: a header value is kept only when it is a single whole {{secret:NAME}} reference, with or without inner padding. Bearer {{secret:x}}, two references, and plain values are still blanked. A kept value is not overwritten from the seed on restore, because withSeedHeaderValues only fills blank values.

Minor points, not blocking:

  • Removing the old third element (null) from the key also changes the edit and duplicate keys. Any edit drafts in progress at deploy are orphaned once and expire within 7 days. This is the same trade the issue accepts for blank drafts.
  • The restored notice still says "Header values are not saved in drafts". That is no longer quite true for headers whose value is a lone secret reference. A wording tweak can go in a later change.

loop:verdict run=review-20261030-093632-62 sha=6fd23f3eeb7a1d5e24486c7313ef16039ee4a662 verdict=merge

Merged main into the branch: 10a076191cd149e025e8ef2afc47ab8fe164c88c.

loop:carry from=6fd23f3eeb7a1d5e24486c7313ef16039ee4a662 sha=10a076191cd149e025e8ef2afc47ab8fe164c88c

Merged onto main as 83f0ddbd972e75c96faaefec7ecef40d7aeb3866.

loop:merged sha=83f0ddbd972e75c96faaefec7ecef40d7aeb3866 head=10a076191cd149e025e8ef2afc47ab8fe164c88c

Sign up or Sign in to add to the discussion.