Unified searchable select + three-level agent-CLI selection (global/repo/spawn) with skip-layer resolution #66

Closed
opened 2026-07-09 10:49:04 +02:00 by dominik.polakovics · 1 comment

Why

Four operator-visible problems on the spawn surfaces, one structural gap behind them:

  1. The composer's repo picker clips off-screen. .composer-pop-panel hard-codes open-above (web/src/base.cssbottom: calc(100% + 6px), fixed max-height: 15rem) while the composer sits near the top of the page, so with many repos the panel's scroll region is unreachable above the viewport (worst on phone).
  2. Inconsistent selects on the start screen. Repo = custom popover; model/effort = native <select> chips. No typeahead anywhere.
  3. RepoSettings base model/effort are free text with a stale hint ("Free text for now — provider catalogs arrive in M3", RepoSettings.tsx) while the AFK section directly below already uses CatalogSelect.
  4. The agent CLI is not selectable at all. repos.provider is stamped once from reposvc.DefaultProvider; PATCH /repos/{id} rejects provider; AddRepo never offers it.
  5. Resolution is not agent-agnostic. ResolveModelEffort 400s any resolved value missing from the provider's catalog (internal/instance/credential.go). The seeded global default is opus[1m] (claude-shaped): the moment a second provider exists, an AFK run on its repo resolves the claude default and every launch 400s. A provider with an empty efforts catalog can never spawn (HasOption([], "") == false).

The model/effort catalog seam itself already exists and stays as-is: AgentProvider.Models()/Efforts() are provider-owned pinned catalogs served on GET /api/v1/providers (ADR-0021/D14); the start screen already re-catalogs by the selected repo's provider. Codex (#2) just declares its lists.

Decisions (grilled with the operator 2026-07-09 — do not re-litigate)

Provider selection — three levels, D12d-style layering

  • Effective provider = per-spawn pick → repo override → global default. repos.provider becomes nullable (NULL = inherit); new seeded setting provider_default = claude-code. One migration.
  • Symmetric AFK overrides: repos.afk_provider_default (nullable) + global spawn_provider_default_afk (empty = inherit), resolved AFK-first exactly like model/effort (ADR-0021). The AFK model/effort selects in both settings pages render against the AFK-effective provider's catalog and re-catalog when the AFK provider select changes.
  • Composer gets a provider chip only when ≥2 providers are registered — today's single-provider UI stays pixel-identical. The per-spawn pick is ephemeral (resets to the repo's effective provider on repo change and page load). Model/effort picks and the logged-out banner follow the effective provider.
  • AddRepo and RepoSettings get provider selects with an explicit inherit entry; global Settings gets base + AFK provider selects. Settings-page selects are always visible (they answer "which agent does this repo use").
  • POST .../instances (start) accepts an optional provider; PATCH /repos/{id} accepts provider and afk_provider_default. Run rows already store runs.provider — ended chats survive a repo switching agents.

Resolution semantics — the skip-layer rule

  • A default-layer value (model, effort, or provider id) not in the effective provider's catalog (or not in the registry, for provider ids) is treated as unset: resolution falls through to the next layer, final fallback = the catalog's first entry. This is what the SPA's resolveSpawnOption already does and mirrors ADR-0021's FilterSpawnOptions rule for the cross-provider global options bag.
  • Empty catalog (a provider without that knob) resolves to "", omits the CLI flag, and skips validation. Fixes both the claude-shaped-global-default AFK 400 trap and the empty-efforts-can-never-spawn bug.
  • Explicit per-spawn request values stay strict: an unknown requested model/effort/provider is still a 400.
  • Catalogs stay statically pinned per adapter (compat discipline). No live discovery from the CLI.

One select component

  • One hand-rolled Solid module (no new dependencies), used on the composer (chip trigger skin) and the settings pages (form-field trigger skin). It absorbs CatalogSelect's inherit-entry + "(not in catalog)" semantics as props and the repo picker's disabled/status rows (cloning %, clone failed). CatalogSelect is deleted, call sites migrate — no legacy shim.
  • Search input appears automatically at ≥8 options (prop can force on/off). Case-insensitive substring filter over label + value. ARIA listbox pattern; Arrow/Enter/Escape keyboard nav; filter input at the top of the panel (trigger stays a button).
  • Positioning: prefer below, flip+clamp. On open, measure free space against visualViewport (on-screen-keyboard-aware): open below by default, flip above only when below lacks room and above has more, clamp max-height to the free space so the whole panel (with internal scroll) always fits.
  • Provider switch in RepoSettings: the model/effort selects re-catalog; stored foreign values stay, shown with the "(not in catalog)" marker; nothing auto-clears (skip-layer makes stale values harmless at spawn; flipping the provider back restores the old defaults).

Also fix along the way

  • Composer modelPick/effortPick are not catalog-validated when the effective provider changes (repo switch today, provider chip tomorrow) — a foreign pick would submit and 400. Reset picks when the effective provider changes.
  • Remove the stale "provider catalogs arrive in M3" hint.
  • Hide the effort chip entirely when the effective provider's effort catalog is empty (instead of a disabled select).
  • Global Settings currently renders its defaults against providers[0]; render base defaults against the global default provider and AFK defaults against the global AFK-effective provider.

ADR

The implementing agent writes the ADR as part of this issue: three-level provider layering with AFK symmetry, skip-layer resolution, static catalogs, one select module. Amends ADR-0021 (spawn defaults/layering); touches ADR-0025 (composer surface) and ADR-0026 (seam v2 — unchanged, referenced).

Out of scope

  • The Codex adapter itself (#2) — this issue only makes lab ready for it.
  • Per-run provider history UI (History/chat header showing which agent ran) — possible follow-up.
  • Live catalog discovery from the CLI.
## Why Four operator-visible problems on the spawn surfaces, one structural gap behind them: 1. **The composer's repo picker clips off-screen.** `.composer-pop-panel` hard-codes open-above (`web/src/base.css` — `bottom: calc(100% + 6px)`, fixed `max-height: 15rem`) while the composer sits near the top of the page, so with many repos the panel's scroll region is unreachable above the viewport (worst on phone). 2. **Inconsistent selects on the start screen.** Repo = custom popover; model/effort = native `<select>` chips. No typeahead anywhere. 3. **RepoSettings base model/effort are free text** with a stale hint ("Free text for now — provider catalogs arrive in M3", `RepoSettings.tsx`) while the AFK section directly below already uses `CatalogSelect`. 4. **The agent CLI is not selectable at all.** `repos.provider` is stamped once from `reposvc.DefaultProvider`; `PATCH /repos/{id}` rejects `provider`; AddRepo never offers it. 5. **Resolution is not agent-agnostic.** `ResolveModelEffort` 400s any resolved value missing from the provider's catalog (`internal/instance/credential.go`). The seeded global default is `opus[1m]` (claude-shaped): the moment a second provider exists, an AFK run on its repo resolves the claude default and every launch 400s. A provider with an empty efforts catalog can never spawn (`HasOption([], "") == false`). The model/effort catalog seam itself already exists and stays as-is: `AgentProvider.Models()/Efforts()` are provider-owned pinned catalogs served on `GET /api/v1/providers` (ADR-0021/D14); the start screen already re-catalogs by the selected repo's provider. Codex (#2) just declares its lists. ## Decisions (grilled with the operator 2026-07-09 — do not re-litigate) **Provider selection — three levels, D12d-style layering** - Effective provider = per-spawn pick → repo override → global default. `repos.provider` becomes **nullable** (NULL = inherit); new seeded setting `provider_default = claude-code`. One migration. - **Symmetric AFK overrides**: `repos.afk_provider_default` (nullable) + global `spawn_provider_default_afk` (empty = inherit), resolved AFK-first exactly like model/effort (ADR-0021). The AFK model/effort selects in both settings pages render against the **AFK-effective** provider's catalog and re-catalog when the AFK provider select changes. - Composer gets a provider chip **only when ≥2 providers are registered** — today's single-provider UI stays pixel-identical. The per-spawn pick is **ephemeral** (resets to the repo's effective provider on repo change and page load). Model/effort picks and the logged-out banner follow the **effective** provider. - AddRepo and RepoSettings get provider selects with an explicit inherit entry; global Settings gets base + AFK provider selects. Settings-page selects are always visible (they answer "which agent does this repo use"). - `POST .../instances` (start) accepts an optional `provider`; `PATCH /repos/{id}` accepts `provider` and `afk_provider_default`. Run rows already store `runs.provider` — ended chats survive a repo switching agents. **Resolution semantics — the skip-layer rule** - A default-layer value (model, effort, or provider id) not in the effective provider's catalog (or not in the registry, for provider ids) is treated as **unset**: resolution falls through to the next layer, final fallback = the catalog's first entry. This is what the SPA's `resolveSpawnOption` already does and mirrors ADR-0021's `FilterSpawnOptions` rule for the cross-provider global options bag. - **Empty catalog** (a provider without that knob) resolves to `""`, omits the CLI flag, and skips validation. Fixes both the claude-shaped-global-default AFK 400 trap and the empty-efforts-can-never-spawn bug. - **Explicit per-spawn request values stay strict**: an unknown requested model/effort/provider is still a 400. - Catalogs stay statically pinned per adapter (compat discipline). No live discovery from the CLI. **One select component** - One hand-rolled Solid module (no new dependencies), used on the composer (chip trigger skin) **and** the settings pages (form-field trigger skin). It absorbs `CatalogSelect`'s inherit-entry + "(not in catalog)" semantics as props and the repo picker's disabled/status rows (cloning %, clone failed). `CatalogSelect` is deleted, call sites migrate — no legacy shim. - **Search input appears automatically at ≥8 options** (prop can force on/off). Case-insensitive substring filter over label + value. ARIA listbox pattern; Arrow/Enter/Escape keyboard nav; filter input at the top of the panel (trigger stays a button). - **Positioning: prefer below, flip+clamp.** On open, measure free space against `visualViewport` (on-screen-keyboard-aware): open below by default, flip above only when below lacks room and above has more, clamp `max-height` to the free space so the whole panel (with internal scroll) always fits. - Provider switch in RepoSettings: the model/effort selects re-catalog; stored foreign values stay, shown with the "(not in catalog)" marker; **nothing auto-clears** (skip-layer makes stale values harmless at spawn; flipping the provider back restores the old defaults). ## Also fix along the way - Composer `modelPick`/`effortPick` are not catalog-validated when the effective provider changes (repo switch today, provider chip tomorrow) — a foreign pick would submit and 400. Reset picks when the effective provider changes. - Remove the stale "provider catalogs arrive in M3" hint. - Hide the effort chip entirely when the effective provider's effort catalog is empty (instead of a disabled select). - Global Settings currently renders its defaults against `providers[0]`; render base defaults against the global default provider and AFK defaults against the global AFK-effective provider. ## ADR The implementing agent writes the ADR as part of this issue: three-level provider layering with AFK symmetry, skip-layer resolution, static catalogs, one select module. Amends ADR-0021 (spawn defaults/layering); touches ADR-0025 (composer surface) and ADR-0026 (seam v2 — unchanged, referenced). ## Out of scope - The Codex adapter itself (#2) — this issue only makes lab ready for it. - Per-run provider history UI (History/chat header showing which agent ran) — possible follow-up. - Live catalog discovery from the CLI.
Author
Owner

This was generated by AI while landing a PR.

Land-PR validation of #67 — verdict: PASS

Checked:

  • CI: ci / native green on head 096bff6 (4m51s) — SPA eslint/prettier/vitest/vite-build, ui-tagged Go build+test, golangci-lint. ci-nix correctly did not trigger (no nix/go.mod/go.sum changes), so the native gate fully covers this diff. Relied on; nothing re-run.
  • Mergeable: head is based on the current main tip (merge base == main) — no conflict.
  • Conventions: Conventional Commits title; body carries a working Closes #66 (AFK contract holds).
  • Diff review (full read of the backend + SPA wiring): implementation matches every grilled decision in #66 — nullable repos.provider + afk_provider_default with stamped rows reset to NULL (migration 0006, both dialects); ResolveProvider / Registry.DefaultFor / reworked layerSpawnDefault implement strict-explicit + skip-layer-defaults + catalog[0] fallback + empty-catalog → ""; settings seeding moved after provider registration (verified: before AFK loops and the HTTP server start); AFK scheduler/launch resolve through the one shared rule; composer agent chip gated at ≥2 providers, ephemeral pick, model/effort picks reset on effective-provider change, effort chip hidden on empty catalog; settings pages re-catalog against drafted providers; CatalogSelect deleted with semantics preserved in the new Select (incl. "(not in catalog)"); ADR-0030 + CONTEXT.md shipped. New tests pin the full chain matrix and the two-provider AFK scenario.

One cosmetic nit, non-blocking: with zero ready repos the repo chip now reads "(unset)" where the old chip said "Choose repo" (send is disabled in that state anyway).

> *This was generated by AI while landing a PR.* **Land-PR validation of #67 — verdict: PASS** Checked: - **CI**: `ci / native` green on head `096bff6` (4m51s) — SPA eslint/prettier/vitest/vite-build, ui-tagged Go build+test, golangci-lint. `ci-nix` correctly did not trigger (no nix/go.mod/go.sum changes), so the native gate fully covers this diff. Relied on; nothing re-run. - **Mergeable**: head is based on the current `main` tip (merge base == main) — no conflict. - **Conventions**: Conventional Commits title; body carries a working `Closes #66` (AFK contract holds). - **Diff review** (full read of the backend + SPA wiring): implementation matches every grilled decision in #66 — nullable `repos.provider` + `afk_provider_default` with stamped rows reset to NULL (migration 0006, both dialects); `ResolveProvider` / `Registry.DefaultFor` / reworked `layerSpawnDefault` implement strict-explicit + skip-layer-defaults + catalog\[0\] fallback + empty-catalog → `""`; settings seeding moved after provider registration (verified: before AFK loops and the HTTP server start); AFK scheduler/launch resolve through the one shared rule; composer agent chip gated at ≥2 providers, ephemeral pick, model/effort picks reset on effective-provider change, effort chip hidden on empty catalog; settings pages re-catalog against drafted providers; `CatalogSelect` deleted with semantics preserved in the new `Select` (incl. "(not in catalog)"); ADR-0030 + CONTEXT.md shipped. New tests pin the full chain matrix and the two-provider AFK scenario. One cosmetic nit, non-blocking: with zero ready repos the repo chip now reads "(unset)" where the old chip said "Choose repo" (send is disabled in that state anyway).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#66
No description provided.