Automations and webhooks for the AT Protocol airglow.run

List picker for the "Add to list" action #41

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

Part of https://tangled.org/exosphere.site/airglow/issues/29. Depends on #40.

Scope #

Replace the hand-entered list URI with a dropdown of the owner's lists, fetched with app.bsky.graph.getLists. In the session the user copied the list URI "800 times".

Details: docs/brainstorms/2026-10-17-graze-conference-automation-feedback.md

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

loop:queued cid=bafyreiezwusa7zle55qiigdqoscnmxup5uj6yhsrmaghi6x3jpwwfumjg4

Working on this, attempt 1.

loop:claim run=contribute-20261026-223038-3my34g2yr7z22 branch=loop/3my34g2yr7z22 attempt=1 issue=41

The session needs an answer from the owner.

feat(automations): list picker for the "Add to list" action

I stopped before changing any code. This issue builds on #40, and #40 is not on main yet. There is no "Add to list" action in the codebase: no lib/actions/* module, no app/islands/action-editors/* editor, and nothing on any remote branch. So there is no list URI field yet to swap for a dropdown.

What I checked #

  • rg -i "addToList|add-to-list|listUri" across the repo turns up only tests and the brainstorm doc. No action exists.
  • git branch -a and git log --all show no branch for #40.
  • Today the only way to add someone to a list is the generic record action targeting app.bsky.graph.listitem. Its list field is a free-text at-uri input in RecordFormBuilder.tsx.

Question for the owner #

Which of these do you want?

  1. Wait for #40. Mark this issue ready again once the "Add to list" action is merged, and I'll put the getLists dropdown into that action's editor. This keeps the issue's scope as written.
  2. Build the picker now on the generic record builder. When the target collection is app.bsky.graph.listitem, the list field becomes a dropdown of the signed-in user's lists, fetched from app.bsky.graph.getLists on the public AppView. Free text stays available for templates like {{...}}. #40 can reuse the component later. This goes beyond the issue as written, so I need your go-ahead.

If you pick option 2, also tell me whose lists to show: the signed-in user's, or the repo the step writes to. Per M1 in the brainstorm, the list in that session belonged to a different account than the one signed in.

loop:awaiting-owner run=contribute-20261026-223038-3my34g2yr7z22 why=needs-info

wait for #40

Working on this, attempt 1.

loop:claim run=contribute-20261030-094448-41 branch=loop/41 attempt=1 issue=41

feat(form): pick the add-to-list action's list from a dropdown of your lists

The "Add to list" action's list field is now a dropdown of the signed-in owner's Bluesky lists, so nobody has to copy a list URI again. A new authenticated route, GET /api/bsky/lists, fetches them with app.bsky.graph.getLists from the public appview for the session user only. An "Other: enter an AT URI..." option still lets you type a URI or a template, and the field falls back to the typed AT URI input when the lists cannot be loaded or the owner has none.

What changed #

  • lib/actions/bsky-lists.ts: fetchOwnedLists(did) calls https://api.bsky.app/xrpc/app.bsky.graph.getLists (limit 100, follows the cursor, at most 5 pages). It keeps uri, name, purpose and item count, and drops entries outside the owner's repo. It returns null when the appview errors, so the editor can tell "no lists" apart from "could not ask".
  • app/routes/api/bsky/lists.ts: GET /api/bsky/lists behind requireApiAuth. It always uses the session DID (no actor parameter) and answers 502 when the appview fails.
  • app/islands/action-editors/bsky-list-add.tsx: a ListField with these states:
    • loading: a disabled select reading "Loading your lists...".
    • lists loaded: a select labelled "List", with options like "Attendees (user list, 3 accounts)" and "Other: enter an AT URI...". Choosing Other shows a "List AT URI" field under it.
    • failed or empty: the old "List AT URI" input, with a hint that says which case it is.
    • A saved value that is not one of the lists (a template, or a deleted list) opens on Other with its text kept.

Verified #

  • pnpm run check passes, and so does pnpm run test (181 files, 2772 tests).
  • New tests:
    • lib/actions/bsky-lists.test.ts: request shape, paging, the page cap, filtering, the empty list, and failures.
    • app/routes/api/bsky/lists.test.ts: session DID only, and the 502.
    • bsky-list-add.test.tsx: the dropdown's options, labels and required; picking a list; Other showing and hiding the URI field; saved values; both fallbacks; and focus. Focus stays on the select when you pick a list, and stays in the URI field when you paste one of the lists' URIs into it. With the "pin Other" line taken out, that last test fails.
  • Not tried in a browser: this container has no local PDS or curl, so I could not sign in. The controls are a native <select> and <input> with <label for> names, and the keyboard and focus checks ran in happy-dom.

Acceptance criteria #

Decisions you can reverse #

  • Public appview, not a proxied call through the owner's PDS. Lists are public, and a proxied app.bsky call would need more than the BASIC OAuth scope that sign-in grants. One downside: in local dev, the *.pds.dev accounts are not indexed by api.bsky.app, so the editor shows "You have no Bluesky lists yet" and the typed field.
  • The dropdown shows every purpose (user, moderation and reference lists), each labelled with its purpose, because a list item works in all of them. To show only user lists, pass purposes=curatelist to getLists.
  • No server-side cache. Each editor fetches once when it mounts, so a list made on Bluesky shows up the next time the form opens.
  • Other keeps the typed URI field for templates such as at://{{self}}/app.bsky.graph.list/{{...}}. The server's owner check is unchanged.
  • The read-only display block still shows the list's URI, not its name. Showing the name would need another fetch on the detail page.

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

loop:pushed run=contribute-20261030-094448-41 sha=134a843a413a3660d8c010c7166c74989c84c997

Verdict: merge. The branch replaces the typed list URI in the "Add to list" action with a dropdown of the signed-in owner's lists. The lists come from app.bsky.graph.getLists through a new authenticated route that only ever uses the session DID. The typed URI field is still there, under "Other", and as the fallback when the lists can't be loaded or the owner has none. Check and tests are green, and nothing is left for the owner to decide.

What I checked and how:

  • Checks and tests: pnpm run check passes. pnpm run test passes: 181 files, 2772 tests.
  • The issue: the issue asks for a dropdown of the owner's lists, fetched with getLists. I checked both against the code, not against pr.md:
    • ListField in app/islands/action-editors/bsky-list-add.tsx
    • fetchOwnedLists in lib/actions/bsky-lists.ts, which calls app.bsky.graph.getLists with the session DID as actor.
  • Other places with the same problem: I searched for every other place a list URI is typed by hand (app.bsky.graph.list, BSKY_LIST_COLLECTION). The only editor that takes one is this action's, so nothing is missed.
  • Who can see the lists: the form only appears on new, edit and duplicate pages, and edit and duplicate are gated by isOwnerOf. The session user is therefore always the automation owner, which makes "the session user's lists" the right ones. The import of OwnedList into the island is type-only, so no server code reaches the client.
  • Real data: I ran fetchOwnedLists against the live api.bsky.app.
    • For bsky.app's DID it returned the right uri, name, purpose and count.
    • For the local alice DID it returned [], so the editor shows the "no lists yet" fallback, as pr.md says.
  • In the real form: I wrote a throwaway test that mounts the whole AutomationForm, seeded with a bsky-list-add step and a mocked /api/bsky/lists, and removed it afterwards. It checked these steps:
    • The saved list opens selected.
    • Picking another list keeps focus on the select and updates the JSON preview.
    • Picking "Other" keeps focus on the select and shows the URI field with the current value.
    • Typing a template, and then a URI from another account's repo, keeps focus in that field and reaches the payload.
    • Going back to a list removes the URI field and keeps focus on the select.
    • The "Who to add" select still keeps its focus.
  • Rest of the form: the nested stableRegion follows the documented pattern. The submitted value stays a plain list string, so the server-side owner check and the foreign-repo warning (#12) work as before. Literal owner-DID URIs don't trigger that warning.
  • Keyboard and labels: the dropdown and the field are a native <select> and <input> with <label for>. Their names, "List" and "List AT URI", tell them apart.

I would not ask for any of the reversible decisions in pr.md to change: the public appview, showing every list purpose, no cache, and the display block still showing the URI. The display block is outside this issue's scope.

loop:verdict run=review-20261030-095815-41 sha=134a843a413a3660d8c010c7166c74989c84c997 verdict=merge

Merged main into the branch: 49befceab0afe16b90ce357b1912a8a9108afdf9.

loop:carry from=134a843a413a3660d8c010c7166c74989c84c997 sha=49befceab0afe16b90ce357b1912a8a9108afdf9

Merged onto main as 49d36ace7b9ce08065fd15d988e31522ff806653.

loop:merged sha=49d36ace7b9ce08065fd15d988e31522ff806653 head=49befceab0afe16b90ce357b1912a8a9108afdf9

Sign up or Sign in to add to the discussion.