Multi-select async owner typeahead filter
Part of epic Task 9387 "Operator credentials roster UX fixes at scale". PRD = Task 9385 and architecture = Task 9386, both settled. IDs AC-x.y and D0–D9 are the PRD's; A1–A22 are the architecture's. Everything this child needs is restated here. Base: `develop` already contains Task 9378; never re-implement it.
Original work item (verbatim):
> Follow-up to Task 9378 (operator credentials page UX). That task shipped server-side sort (Created/Name/Type/Org), a "1–25 of N" count, and shows the tenant once per row. These items remain for an operator working across all tenants' credentials, up to millions of rows:
> 1. **Secrets filter scales.** `?secrets=` decrypts every row in scope on each request (`apps/api/app/routes/credentials/reads.py::_secrets_filter`) and puts every id into an `IN` list (Postgres allows at most 65535). Store a "has stored secret" boolean when the credential is written, so the filter becomes an indexed predicate.
> 2. **Paging without full counts.** Replace exact `COUNT(*)` + OFFSET with keyset (cursor) paging and a capped/estimated total ("10,000+"). Add indexes behind each sort order (`lower(name)`, `type`, org name).
> 3. **Search that finds things at scale.** Add a pg_trgm index on name/description. For operators, also match org name/slug and owner email.
> 4. **Owner picker as an async typeahead.** A native select cannot hold thousands of owners across tenants. Make it multi-select.
> 5. **Tree mode beyond 200 rows.** Build tree mode from a server-side folder listing (folders with counts, children loaded on open) instead of one 200-row page.
> 6. **Bulk actions.** Let operators select rows (per page, plus "all N matching") and then delete, change org or move them to a folder.
> 7. **Health columns and filters.** Add last used, last test result, and expired/expiring/never used, each also available as a filter.
>
> Out of scope: the three items Task 9378 shipped; credential editor pages; runner credential resolution.
>
> Done means:
> - Each item above is shipped or explicitly declined with a reason.
> - With 1M credentials in a dev DB, the roster's first page with any filter or sort loads in under 1s (EXPLAIN shows an index scan).
> - An operator can bulk-delete 500 matching credentials in one action.
Shared rules:
- D1: "operator" means a principal on the operator plane holding cross-tenant `credentials:view`.
- No scope widens.
- No secret or config value appears in any response.
- UI copy follows `docs/standards/ui-copy-standards.md`; layout follows `docs/standards/ui-style-guide.md`.
## This child (architecture C4): owner typeahead, multi-select
Covers item 4. It needs repeatable `owner_id` on the roster, from the "Keyset roster query builder and API" child.
Approach (settled decisions):
- **A13 — owners endpoint** `GET /api/credentials/owners?q=&limit=1..50&ids=uuid…`, default limit 20.
- Matches name or email through the trigram indexes (`ix_admin_users_email_trgm` and the name index).
- Restricted with `EXISTS`: the owner must own a credential within the caller's roster scope, the same scope as today's `list_credential_owners` in `reads.py`.
- Order: prefix matches first, then name, then id. This deviates from AC-4.1's "ordered by credential count", because a GROUP BY over 1M rows on every keystroke is unaffordable.
- `credential_count` is returned only for the ≤20 owners shown, as a LATERAL capped count (cap 10,001, shown "10,000+"), with a new `count_capped: bool`.
- `ids=` resolves stored selections to names.
- With no `q` and no `ids`, it returns today's full list unchanged, so existing callers keep working.
- **A21 — the picker.** Add `apps/web/src/ui/AsyncMultiLookup.tsx` beside `AsyncLookup.tsx`.
- Factor the shared fetch/debounce/abort machinery into a hook, `useAsyncOptions`, that both components use.
- Build it on MUI `Autocomplete multiple` with chips (pattern: `components/tasks/TaskTags.tsx`).
- **Wiring.**
- `CredentialFilterStrip.tsx` swaps the native owner `<select>` for the new picker.
- Selections persist as ids through `credentialFilterStorage.ts` and are resolved to names via `ids=` on load.
- Keyless callers keep the 403 path and see no picker.
- **Explain case.** Add the owner-lookup case (operator, 3-char `q` on the 1M seed) to `tools/perf/credentials_roster_explain.py`.
Credentials & reach (architecture §9, verbatim; "C1's script" = the foundation child's seed script):
- "Unit/API tests for every AC | Nothing beyond the worktree (`./dc test`). | verified — same."
- "AC-P (1M rows, EXPLAIN < 1 s) | Nothing beyond the worktree: the slot's local compose DB (`./dc psql`), seeded by C1's script. | verified — needs only the worktree; the stack is started by the coder (`./dc up`)."
Out of scope:
- MUST NOT re-implement or change Task 9378's sort control, "1–25 of N" count format (other than the capped variant) or once-per-row tenant display.
- MUST NOT change credential editor pages: `apps/web/src/pages/credentials/CredentialEditPage.tsx`, `AccountEditPage.tsx`, `credentialForm.tsx`, nor the Accounts page (`Accounts.tsx`).
- MUST NOT change runner credential resolution: what `routes/agent/task_secrets.py` and the credential-get path return, who may call them, or path resolution (`runner_path`). Only the additive best-effort `last_used_at` stamp (D6) is allowed.
- MUST NOT change the sealing scheme (`app/credential_crypto.py`) or put config into SQL predicates.
- MUST NOT make health columns sortable, add "jump to page N", or add bulk verbs beyond delete / change org / move to folder.
- MUST NOT change `CredentialSelect` or other `page`-based pickers' behavior.
- Do NOT touch the paging, search or health UI, the tree, or bulk actions (sibling children). Change the roster query only to consume `owner_id`.
Done means (PRD criteria verbatim with their oracles; the A13 deviation is marked):
- **4.1** The Owner filter is an async typeahead (reuse the `apps/web/src/ui/AsyncLookup.tsx` pattern): typing ≥1 char queries the server (`/api/credentials/owners?q=&limit=`), matching name or email, at most 20 results, ordered **prefix match first, then name** (A13 deviation from "credential count then name"), scoped exactly as today. → verify: API test for `q` + limit + scope + order; web test types a name and sees server results.
- **4.2** Multiple owners can be chosen; chips show each owner's name (never a raw id), each removable; the roster filters to the union. → verify: a web test selects two owners, removes one, and asserts the request's `owner_id` values.
- **4.3** Selected owners survive reload (existing filter persistence, `apps/web/src/lib/credentialFilterStorage.ts`), and a stored owner whose name must be shown is resolved by id. → verify: a web test reloads and sees names, not ids.
- **4.4** Keyless callers still get no owner roster (403 path) and the picker stays hidden. → verify: existing test passes.
- **4.5** Usable at phone width: chips wrap, no horizontal page scroll. → verify: e2e at the narrowest supported width.
- Tightened: chips show full names, ellipsized with a tooltip only when longer than the field. The input stays usable with three chips selected.
- Attach the screenshot to the PR.
- The explain script's owner-lookup case runs in under 1000 ms with no `Seq Scan on credentials` on the 1M seed. The output goes in the PR.
- `./dc check quick` is green.
Outcome
Owner filter is now a multi-select async typeahead. /owners gained q, limit and ids, with prefix-first order, capped counts and a name trigram index. Explain run on 1M rows: 27 ms. Quick gate green; review lane passed. PR #6755 merged 06ce983; its deploy was superseded by a newer one. Memory: proposed mem-24743.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)