diff --git a/devlog/_plan/260912_provider_catalog_unified_search/040_r1_unified_search.md b/devlog/_plan/260912_provider_catalog_unified_search/040_r1_unified_search.md index e0d0f11b45..45a7f084dc 100644 --- a/devlog/_plan/260912_provider_catalog_unified_search/040_r1_unified_search.md +++ b/devlog/_plan/260912_provider_catalog_unified_search/040_r1_unified_search.md @@ -70,3 +70,28 @@ comparator, and the alias rules all live in `provider-presets.ts` and are unit-t without React. The component test covers: a query with zero hits in the active tab leaves `tier` untouched; tab click with a non-empty query does not clear the query; an in-flight Accounts login keeps its `LoginHint` and paste field across a chip click. + +## What changed against this doc during the build + +Five things the plan did not anticipate, each found by an independent reviewer or by +driving the surface: + +- **The group heading is not sticky.** A sticky bar needs an opaque background, and + `.modal-card` is a translucent glass panel, so it seamed against the card behind it — + and it read as a grey slab. It is now a small-caps `

` with a hairline rule that + scrolls with the content; the chips above are already the index. +- **The chip lookup cannot use `CSS.escape`.** Group ids come from `useId`, which emits + colons, so selecting on one needs `CSS.escape` — and `CSS` does not exist in the + happy-dom environment the GUI tests run in, so a chip click would have thrown + `ReferenceError` on CI rather than merely being untested. The heading carries a + `data-catalog-group` attribute instead. +- **The jump scrolls the list itself**, and focuses with `preventScroll`. `.modal-card` + is also a scroll container, so both `scrollIntoView` and a plain `focus()` could drag + the search field out of view while jumping between groups. +- **Loading and empty states key off what is actually on screen.** Using the total match + count meant the unfiltered Accounts bucket — which almost always holds an OpenAI login + row — kept a still-loading Free tab from ever saying it was loading. `visibleCount` is + the selected tab's own row count while browsing. +- **The login/preset dedupe is a named helper**, `dropPresetsCoveredByAccounts`, rather + than a `Set` inside the memo, so the rule that a login row outranks a preset of the + same id has its own unit test. diff --git a/devlog/_plan/260912_provider_catalog_unified_search/060_delivery_record.md b/devlog/_plan/260912_provider_catalog_unified_search/060_delivery_record.md new file mode 100644 index 0000000000..3a8528892b --- /dev/null +++ b/devlog/_plan/260912_provider_catalog_unified_search/060_delivery_record.md @@ -0,0 +1,52 @@ +# 060 — Delivery record + +Four dependent pull requests onto `dev`, one per work phase, every push `--no-verify`, +no local test suite run at any point. Verified 2026-09-12. + +| PR | branch | base | head | live CI run | +|---|---|---|---|---| +| #4324 | `codex/provider-catalog-plan` | `dev` | `67657b9655` | `34666254983` success | +| #4325 | `codex/provider-catalog-local-tab` | #4324 head | `7ae08971c7` | `34668670304` success | +| #4328 | `codex/provider-catalog-note-popup` | #4325 head | `e3fdf8fd26` | `34668670844` success | +| #4331 | `codex/provider-catalog-unified-search` | #4328 head | `68e6b028d7` | `34668669980` success | + +Ancestry is a real chain, checked with `git merge-base --is-ancestor`: +`67657b9655` ⊂ `7ae08971c7` ⊂ `e3fdf8fd26` ⊂ `68e6b028d7`. Each PR's diff against its +own base carries only that phase's work; no parent commit is replayed. + +## Two CI facts that are easy to misread + +**Cancelled duplicate runs leave FAILURE rows on a live head.** Pushing the rebased +chain started overlapping workflow runs, and the concurrency group cancelled the older +ones. A cancelled run's `ci` aggregator concludes *failure* with +`needed job(s) did not pass: changes=cancelled`, and that row stays attached to the same +head SHA as the live green run. On `e3fdf8fd26` the failing `ci` is job `103485691739` +on cancelled run `34668670665`; the live aggregator `103488063420` on `34668670844` +succeeded. Read the run, not the rollup. + +**A cancelled required check is not a passing one.** All three `enforce-target` +attempts on `e3fdf8fd26` were cancelled the same way, which left #4328 `UNSTABLE` even +though its product CI was green — `gh pr checks` maps a cancelled required check to +fail. Re-running `34668680065` produced a success on that exact head and the PR went +`CLEAN`. This was found by an independent auditor, not by reading the rollup. + +#4324 reports `BLOCKED` because `dev` requires a pull-request review; that is branch +protection, not a check failure. + +## What CI proved that local runs did not + +The test suite was never run in this worktree, by instruction. Two defects were caught +that a local run would have caught instantly, and both were found by static review +instead: + +- `CSS.escape` in the chip jump would have thrown `ReferenceError` under bun/happy-dom, + taking the new chip test red. Replaced with a `data-catalog-group` attribute lookup + before it ever reached CI. +- A third defect did reach CI: `gui/tests/fr-localization.test.ts` rejects a French + value identical to its English source, and `modal.tab.local` is `"Local"` in both. + "Local" is genuinely the same word in French and the Local *badge* was already on that + allowlist, so the tab joined it. Fixed at the root of the stack and the two children + were rebased onto it, which is why #4328 and #4331 were force-pushed once. + +Local checks that were run: `bun x tsc --noEmit`, `cd gui && bun x tsc --noEmit -p +tsconfig.json`, and `bun run structure:check`. Everything else is **NOT RUN** locally. diff --git a/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-browse-mode.png b/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-browse-mode.png new file mode 100644 index 0000000000..d311c5d86f Binary files /dev/null and b/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-browse-mode.png differ diff --git a/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-search-accounts.png b/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-search-accounts.png new file mode 100644 index 0000000000..8b4d963812 Binary files /dev/null and b/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-search-accounts.png differ diff --git a/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-search-groups.png b/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-search-groups.png new file mode 100644 index 0000000000..cf77ac92b8 Binary files /dev/null and b/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-search-groups.png differ diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 7e86901233..a077b39f28 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -87,7 +87,7 @@ badge or the version value to read the full value. | **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | | **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | | **Providers** | Add, edit, set the default (enabled providers only), enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Removing the current default switches to the first remaining enabled provider when one exists; otherwise deletion is refused and the current default is kept. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. The Provider Overview shown when no provider is selected carries a **Refresh all quotas** control that forces one server-side re-read of every configured provider; a provider whose upstream probe fails keeps its last-good row, so the status line reports that the check completed rather than claiming every value is fresh, and each row's own age stays the per-provider freshness signal. | -| **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. | +| **Add provider** | One search above the tabs reaches all four at once — Accounts, Free, Local, Paid. While a query is live the results are grouped by tab with a count each, and the selected tab stays put rather than jumping. Local runtimes (Ollama, vLLM, LM Studio, LiteLLM) have their own tab, and a long provider note clamps to two lines with the full text one click away. | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | | **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index efdd80179f..4ae2762b1a 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -39,7 +39,7 @@ bun run dev:gui | **Windows 트레이** | 로그인할 때 사용자 전용 트레이를 시작하고 프록시 시작·중지·재시작·대시보드·상태를 클릭으로 제어합니다. 트레이는 재시작 서비스가 아닙니다. | | **Codex 자동 시작** | 이미 설치된 Codex launcher shim이 `ocx ensure`를 실행하도록 허용합니다. 이 토글은 shim이나 백그라운드 서비스를 설치하지 않습니다. | | **Providers** | 프로바이더를 추가, 편집, 기본으로 설정(활성만), 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. 현재 기본 프로바이더를 제거하면 남아 있는 첫 번째 활성 프로바이더로 전환됩니다(있는 경우); 없으면 삭제가 거부되고 현재 기본이 유지됩니다. Claude(Anthropic) OAuth 풀에서는 로그인한 계정마다 자체 5시간·주간 한도 막대가 표시되며(사용량은 자격 증명 단위), 조회 실패 시 마지막 값을 유지하고 일시 불가 상태로 표시합니다. 프로바이더를 선택하지 않았을 때 보이는 Provider Overview에는 **전체 할당량 갱신** 버튼이 있어 설정된 모든 프로바이더를 서버에서 한 번에 다시 읽습니다. 조회에 실패한 프로바이더는 마지막 값을 유지하므로, 상태 문구는 모든 값이 새로 왔다고 말하지 않고 조회가 끝났다고만 알리며, 각 행의 확인 시각이 프로바이더별 신선도를 보여 줍니다. | -| **Add provider** | 레지스트리 기반 프리셋에서 계정 로그인, API key 서비스, 로컬 서버, custom endpoint를 검색합니다. | +| **Add provider** | 탭 위의 검색창 하나가 계정·무료·로컬·유료 네 탭을 한 번에 찾습니다. 검색 중에는 선택한 탭이 움직이지 않고, 결과를 탭별로 묶어 개수와 함께 보여 줍니다. 로컬 런타임(Ollama, vLLM, LM Studio, LiteLLM)은 별도 탭을 쓰며, 긴 설명은 두 줄로 줄고 클릭하면 전체가 열립니다. | | **Codex Auth** | ChatGPT/Codex 풀 계정을 추가하고, 다음 세션 계정을 선택하고, 5시간 / 주간 / 30일 할당량을 갱신하며, 할당량 자동 전환을 켜거나 끄고 1~100% 임계값과 일시적 실패 failover를 설정합니다. | | **Subagents** | `spawn_agent` override 목록에 네이티브 또는 라우팅 모델을 최대 5개까지 우선 노출합니다. | | **Models** | 네이티브 GPT와 라우팅 모델을 켜고 끄고, 프로바이더 allowlist와 컨텍스트 상한, v1/base/v2, v2 thread 수를 설정합니다. | diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 4600b51ace..05b11a8780 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -39,7 +39,7 @@ bun run dev:gui | **Трей Windows** | Устанавливает пользовательский значок входа для запуска, остановки, перезапуска, панели и состояния прокси одним щелчком. Трей не является службой перезапуска. | | **Автозапуск Codex** | Разрешает уже установленному launcher shim Codex выполнять `ocx ensure`. Переключатель не устанавливает shim или фоновую службу. | | **Providers** | Добавление, редактирование, назначение провайдера по умолчанию (только включённые), включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. При удалении текущего провайдера по умолчанию выбирается первый оставшийся включённый провайдер, если он есть; иначе удаление отклоняется и текущий default сохраняется. Для пулов Claude (Anthropic) OAuth у каждого вошедшего аккаунта свои полосы 5-часового и недельного лимита (использование по учётным данным); при сбое опроса сохраняются последние известные значения с пометкой недоступности. Обзор Providers, который показывается, когда провайдер не выбран, содержит кнопку **Обновить все квоты**: она принудительно перечитывает на сервере все настроенные провайдеры за один раз. Провайдер с неудачным опросом сохраняет последнее известное значение, поэтому статус сообщает лишь о завершении проверки, а не о том, что все значения свежие; время проверки в каждой строке остаётся признаком актуальности для конкретного провайдера. | -| **Add provider** | Поиск по пресетам из реестра: вход по аккаунту, сервисы с API-ключом, локальные серверы или пользовательская конечная точка. | +| **Add provider** | Одно поле поиска над вкладками ищет сразу по всем четырём — Аккаунты, Бесплатные, Локальные, Платные. Во время поиска выбранная вкладка не переключается, а результаты группируются по вкладкам с количеством. У локальных сред выполнения (Ollama, vLLM, LM Studio, LiteLLM) своя вкладка, а длинное описание сворачивается до двух строк и открывается целиком по клику. | | **Codex Auth** | Добавление аккаунтов пула ChatGPT/Codex, выбор аккаунта для следующей сессии, обновление квот 5 ч / недельных / 30-дневных, включение или отключение автопереключения, настройка его порога 1–100% и failover при временных сбоях. | | **Subagents** | Выделение до пяти «голых» нативных или маршрутизируемых моделей с пространством имён в списке переопределений `spawn_agent`. | | **Models** | Включение и отключение нативных GPT и маршрутизируемых моделей, настройка списков разрешённых провайдеров и лимитов контекста, выбор v1/base/v2 и настройка лимита потоков v2. | diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index dbb6383120..62eaad0bc2 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -38,7 +38,7 @@ bun run dev:gui | **Windows 托盘** | 安装用户登录托盘,一键控制代理启动、停止、重启、面板和状态。托盘不是代理重启服务。 | | **Codex 自动启动** | 允许已安装的 Codex launcher shim 运行 `ocx ensure`。此开关不会安装 shim 或后台服务。 | | **Providers** | 添加、编辑、设为默认(仅已启用)、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。删除当前默认时,会切换到剩余的第一个已启用 provider(若存在);否则拒绝删除并保留当前默认。Claude(Anthropic)OAuth 池中,每个已登录账号显示各自的 5 小时与周限额条(用量按凭证计);探测失败时保留上次已知数值并标记为暂时不可用。 未选中任何 provider 时显示的 Providers 概览带有**刷新全部额度**按钮,会在服务端一次性重新读取所有已配置的 provider;上游探测失败的 provider 会保留上次已知数值,因此状态文案只表示检查已完成,而不声称每个数值都是最新的,各行自身的检查时间仍是该 provider 的新鲜度信号。 | -| **Add provider** | 搜索 registry preset,选择账号登录、API key 服务、本地服务器或自定义 endpoint。 | +| **Add provider** | 标签页上方的单个搜索框可同时搜索账号、免费、本地、付费四个标签页。搜索时选中的标签页不会跳转,结果按标签页分组并显示数量。本地运行时(Ollama、vLLM、LM Studio、LiteLLM)拥有独立标签页,过长的说明会截断为两行,点击即可查看全文。 | | **Codex Auth** | 添加 ChatGPT/Codex 池账号,选择下一 session 的账号,刷新 5h / 每周 / 30d 配额,启用或停用配额自动切换,设置其 1–100% 阈值和临时故障 failover。 | | **Subagents** | 在 `spawn_agent` override 列表中置顶最多五个原生或路由模型。 | | **Models** | 开关原生 GPT 与路由模型,配置 provider allowlist、上下文上限、v1/base/v2 以及 v2 thread 数量。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index 358624f2f9..3b7005fd76 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -43,7 +43,7 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 | **Windows 托盤** | 安裝使用者登入托盤,一鍵控制代理啟動、停止、重啟、面板和狀態。托盤不是代理重啟服務。 | | **Codex 自動啟動** | 允許已安裝的 Codex launcher shim 執行 `ocx ensure`。此開關不會安裝 shim 或後臺服務。 | | **Providers** | 新增、編輯、啟用/停用、刪除 provider,並在支援時管理 OAuth 帳號池和 API key 池。 | -| **Add provider** | 搜尋 registry preset,選擇帳號登入、API key 服務、本機伺服器或自訂 endpoint。 | +| **Add provider** | 分頁上方的單一搜尋框可同時搜尋帳號、免費、本機、付費四個分頁。搜尋時選取的分頁不會跳轉,結果依分頁分組並顯示數量。本機執行環境(Ollama、vLLM、LM Studio、LiteLLM)有專屬分頁,過長的說明會截斷為兩行,點擊即可查看全文。 | | **Codex Auth** | 新增 ChatGPT/Codex 池帳號,選擇下一 session 的帳號,重新整理 5h / 每週 / 30d 配額,啟用或停用配額自動切換,設定其 1–100% 閾值和臨時故障 failover。 | | **Subagents** | 在 `spawn_agent` override 列表中置頂最多五個原生或路由模型。 | | **Models** | 開關原生 GPT 與路由模型,設定 provider allowlist、上下文上限、v1/base/v2 以及 v2 thread 數量。 | diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 8a55273e20..f19abbb836 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -66,6 +66,10 @@ export default function AddProviderModal({ // The full-note popup is owned here, not in the catalog: it has to render as a sibling // of this overlay, and its open state has to be visible to the Escape handler below. const [notePreset, setNotePreset] = useState(null); + // The unified search text is owned here for the same reason: Escape has to clear a + // non-empty query instead of closing the dialog, and the handler that decides is this + // component's. + const [catalogQuery, setCatalogQuery] = useState(""); const oauthPoll = useKeyedClientResource( `add-provider-oauth:${apiBase}`, @@ -132,11 +136,20 @@ export default function AddProviderModal({ // This listener is on `window` and does not read `defaultPrevented`, so a native // cancel does not stop it. Every stacked overlay has to be named here or // Escape closes the whole add-provider modal out from under it. - if (e.key === "Escape" && !oauthTosPending && !notePreset) onClose(); + // Kept as a `!oauthTosPending` expression on purpose: tests/gui/oauth-tos-warning.test.ts + // source-scans this file for that exact substring, because the guard is the only thing + // stopping Escape from closing the modal out from under a stacked overlay. + const noOverlayOpen = !oauthTosPending && !notePreset; + if (e.key !== "Escape" || !noOverlayOpen) return; + // Escape unwinds one layer at a time: the note popup, then a live search, then the + // dialog. Closing the modal on the keystroke that was meant to clear a query throws + // away everything the user typed into the form behind it. + if (catalogQuery) { setCatalogQuery(""); return; } + onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [onClose, oauthTosPending, notePreset]); + }, [onClose, oauthTosPending, notePreset, catalogQuery]); const presetDescription = (candidate: Preset): string | undefined => { const key = codexPresetDescriptionKey(candidate); @@ -263,6 +276,8 @@ export default function AddProviderModal({ usageRank={usageRank} presetsLoading={presetsLoading} initialTier={initialTier} + query={catalogQuery} + onQueryChange={setCatalogQuery} onSelectPreset={p => choosePreset(p)} onSelectCustom={() => choosePreset(fallbackPresets[0]!)} onShowNote={p => setNotePreset(p)} diff --git a/gui/src/components/provider-catalog/CatalogAccountRow.tsx b/gui/src/components/provider-catalog/CatalogAccountRow.tsx new file mode 100644 index 0000000000..e5f824225d --- /dev/null +++ b/gui/src/components/provider-catalog/CatalogAccountRow.tsx @@ -0,0 +1,130 @@ +/** + * One Accounts-tab login row. Split out of ProviderCatalog when unified search made the + * list composition (four groups, headings, jump chips) the interesting part of that file + * and this row's nine-way button matrix the noise. Behaviour is unchanged. + */ +import { useT } from "../../i18n/shared"; +import { LoginHint } from "../login-url-block"; +import { ProviderIcon } from "../provider-workspace/ProviderRail"; +import { shouldShowLoginHint, type CatalogLoginHint } from "./login-hint-visibility"; +import type { AccountLoginRow, AccountLoginStatus } from "./account-row-types"; + +export default function CatalogAccountRow({ + row, + status, + busyProvider, + loginHint, + paste, + onLogin, + onCancelLogin, + onLogout, + onManage, +}: { + row: AccountLoginRow; + status?: AccountLoginStatus; + busyProvider: string | null; + loginHint: CatalogLoginHint | null; + paste?: { + value: string; + busy: boolean; + message: string; + ok: boolean; + onChange: (value: string) => void; + onSubmit: (provider: string) => void; + }; + onLogin?: (provider: string, addAccount?: boolean) => void; + onCancelLogin?: (provider: string) => void; + onLogout?: (provider: string) => void; + onManage?: (provider: string) => void; +}) { + const t = useT(); + const busy = busyProvider === row.id; + const loggedIn = !!status?.loggedIn; + const statusText = loggedIn + ? (status?.email ?? row.statusLabel ?? t("modal.accountLoggedIn")) + : (status?.error ?? row.statusLabel ?? t("modal.accountLoggedOut")); + // A first-time add is the one moment the operator has no other way in: + // the provider has no workspace panel yet, so without this the + // authorization URL is computed and never drawn. + const showHint = shouldShowLoginHint(row, busyProvider, loginHint); + return ( +
+
+ {/* Account rows are providers too. A logo beside Cursor and a bare + tile beside Kiro reads as a bug, not as a distinction. */} + +
+
{row.label}
+
{statusText}
+
+
+ {row.kind === "key" ? null : row.kind === "codex" ? ( + <> + {loggedIn && ( + {t("modal.accountManage")} + )} + {onLogin && ( + + )} + + ) : loggedIn ? ( + <> + {onManage && ( + + )} + {onLogin && ( + + )} + {busy && onCancelLogin && ( + + )} + {onLogout && !busy && ( + + )} + + ) : busy ? ( + onCancelLogin && + ) : ( + onLogin && + )} +
+
+ {showHint && loginHint && ( + paste.onSubmit(row.id), + }, + } + : {})} + /> + )} +
+ ); +} diff --git a/gui/src/components/provider-catalog/ProviderCatalog.tsx b/gui/src/components/provider-catalog/ProviderCatalog.tsx index d3122316db..b0e7f50992 100644 --- a/gui/src/components/provider-catalog/ProviderCatalog.tsx +++ b/gui/src/components/provider-catalog/ProviderCatalog.tsx @@ -4,29 +4,25 @@ * login rows on the Accounts tab. Presentational: presets/usage arrive via props; * view state (tab, query) lives here; selection lifts up. */ -import { useMemo, useState } from "react"; +import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react"; import { useT } from "../../i18n/shared"; import { bucketPresets, pinSponsors, - filterPresets, noteNeedsReveal, + matchesCatalogQuery, + sortCatalogMatches, + filterAccountRows, + dropPresetsCoveredByAccounts, type CatalogPreset, type CatalogTier, } from "./provider-presets"; -import { shouldShowLoginHint, type CatalogLoginHint } from "./login-hint-visibility"; -import { LoginHint } from "../login-url-block"; +import { type CatalogLoginHint } from "./login-hint-visibility"; +import CatalogAccountRow from "./CatalogAccountRow"; +import type { AccountLoginRow, AccountLoginStatus } from "./account-row-types"; import { ProviderIcon } from "../provider-workspace/ProviderRail"; -export type AccountLoginStatus = { loggedIn: boolean; email?: string; error?: string; needsReauth?: boolean }; -export type AccountLoginRow = { - id: string; - label: string; - kind: "oauth" | "key" | "codex"; - statusLabel?: string; - /** Optional deep-link for codex/account-pool management. */ - href?: string; -}; +export type { AccountLoginRow, AccountLoginStatus }; export type { CatalogTier }; @@ -49,6 +45,8 @@ export default function ProviderCatalog({ usageRank = EMPTY_USAGE_RANK, presetsLoading = false, initialTier = "free", + query, + onQueryChange, onSelectPreset, onSelectCustom, onShowNote, @@ -66,6 +64,14 @@ export default function ProviderCatalog({ usageRank?: Record; presetsLoading?: boolean; initialTier?: CatalogTier; + /** + * The unified search text, owned by the modal. It lives up there because the + * add-provider modal's Escape handler is on `window` and registers before this + * component's would: Escape has to clear a non-empty query instead of closing the + * dialog, and a child listener never gets the chance. + */ + query: string; + onQueryChange: (value: string) => void; onSelectPreset: (preset: CatalogPreset) => void; onSelectCustom: () => void; /** Open the full-note popup for a row whose note is clamped. Owned by the modal. */ @@ -93,7 +99,19 @@ export default function ProviderCatalog({ }) { const t = useT(); const [tier, setTier] = useState(initialTier); - const [query, setQuery] = useState(""); + const rowsId = useId(); + const groupId = (candidate: CatalogTier) => `${rowsId}-${candidate}`; + const rowsRef = useRef(null); + const searching = query.trim().length > 0; + + /** + * Entering or leaving search mode, and switching tabs, replaces the dataset entirely. + * Restoring an old scroll offset onto a different list lands somewhere meaningless, so + * the list goes back to the top instead. + */ + useEffect(() => { + if (rowsRef.current) rowsRef.current.scrollTop = 0; + }, [tier, searching]); const catalog = useMemo(() => presets.filter(p => p.id !== "custom"), [presets]); @@ -113,7 +131,96 @@ export default function ProviderCatalog({ const buckets = useMemo(() => bucketPresets(pinSponsors(ranked)), [ranked]); const tierList = buckets[tier]; - const rows = useMemo(() => filterPresets(tierList, query), [tierList, query]); + + /** + * Search mode replaces browse mode rather than filtering inside it. While a query is + * live the selected tab is frozen and every group is rendered, because a jump from + * Free to Accounts would not merely change which rows are listed - it changes the kind + * of row, from a preset-select button to a login row with Log in and Add account + * buttons. Clearing the query returns to the tab the user actually chose. + */ + const accountMatches = useMemo( + () => (searching ? filterAccountRows(accountRows, query, busyProvider) : accountRows), + [accountRows, query, searching, busyProvider], + ); + + const presetGroups = useMemo(() => { + const presetTabs = TIER_TABS.filter(candidate => candidate !== "accounts"); + if (!searching) { + return tier === "accounts" ? [] : [{ tier, rows: tierList }]; + } + return presetTabs.map(candidate => ({ + tier: candidate, + rows: sortCatalogMatches( + dropPresetsCoveredByAccounts( + buckets[candidate].filter(p => matchesCatalogQuery(p, query)), + accountMatches, + ), + query, + ), + })); + }, [searching, tier, tierList, buckets, query, accountMatches]); + + const counts = useMemo(() => { + const byTier = Object.fromEntries(presetGroups.map(group => [group.tier, group.rows.length])) as Record; + return { ...byTier, accounts: accountMatches.length } as Record; + }, [presetGroups, accountMatches]); + + const totalMatches = TIER_TABS.reduce((sum, candidate) => sum + (counts[candidate] ?? 0), 0); + const matchedTiers = TIER_TABS.filter(candidate => (counts[candidate] ?? 0) > 0); + + /** + * What is actually on screen right now. In browse mode that is one tab, and it is NOT + * `totalMatches`: the accounts bucket is unfiltered while browsing, and an OpenAI login + * row is almost always present, so keying the loading and empty states off the total + * left a still-loading Free tab rendering a blank pane instead of saying it was loading. + */ + const visibleCount = searching + ? totalMatches + : tier === "accounts" ? accountMatches.length : (presetGroups[0]?.rows.length ?? 0); + + /** + * A chip scrolls its group into view; it does not change `tier`. Focus moves to the + * heading so a keyboard user lands where they aimed - unless a login is in flight, + * because that row owns the paste field the user may be typing into. + */ + const jumpToGroup = (candidate: CatalogTier) => { + const container = rowsRef.current; + // Looked up by data attribute rather than by id: the id comes from `useId`, which + // emits colons, so selecting on it needs `CSS.escape` — and `CSS` does not exist in + // the happy-dom environment the GUI tests run in, so a chip click would throw there + // rather than merely be untested. The tier values are plain lowercase words. + const heading = container?.querySelector(`[data-catalog-group="${candidate}"]`); + if (!container || !heading) return; + // Scroll the list itself rather than calling scrollIntoView: `.modal-card` is also a + // scroll container, so delegating to the browser can drag the search field out of + // view while jumping between groups inside a 360px list. + container.scrollTop = heading.offsetTop - container.offsetTop; + // `preventScroll` for the same reason the scroll is manual: the default would let the + // focus move drag the translucent modal card that the list sits inside. + if (!busyProvider) heading.focus({ preventScroll: true }); + }; + + /** ArrowDown out of the input lands on the first result, never on a chip. */ + const onSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== "ArrowDown") return; + const first = rowsRef.current?.querySelector("button, a[href]"); + if (!first) return; + e.preventDefault(); + first.focus(); + }; + + const groupHeading = (candidate: CatalogTier, count: number) => ( +

+ {t(TIER_TAB_LABEL[candidate])} + {count} +

+ ); const badges = (p: CatalogPreset) => { const auth = p.codexAccountMode === "direct" ? {t("modal.badge.direct")} @@ -136,38 +243,99 @@ export default function ProviderCatalog({ return (
-
- {TIER_TABS.map(candidate => ( - - ))} -
+ {/* Search first, then the filters it overrides. It reaches every tab, so putting it + under one tab's header would say the opposite of what it does. */} + onQueryChange(e.target.value)} + onKeyDown={onSearchKeyDown} + placeholder={t("modal.search")} + aria-label={t("modal.search")} + /> - {tier === "accounts" && ( + {searching ? ( + // Not a tablist any more: the panel below is showing every group, so a `tab` with + // `aria-selected` would announce "Free, selected" over a Paid row. These are jump + // chips with counts, and a chip with no matches is disabled rather than hidden so + // the strip does not reflow under the pointer on every keystroke. +
+ {TIER_TABS.map(candidate => ( + + ))} +
+ ) : ( +
+ {TIER_TABS.map(candidate => ( + + ))} +
+ )} + + {!searching && tier === "accounts" && (
{t("modal.accountsHint")}
)} - setQuery(e.target.value)} - placeholder={t("modal.search")} - /> +
+ {searching + ? (totalMatches === 0 + ? t("modal.noMatch") + : t("modal.searchResults", { + count: totalMatches, + tiers: matchedTiers.map(candidate => t(TIER_TAB_LABEL[candidate])).join(", "), + })) + : ""} +
-
- {presetsLoading && rows.length === 0 && ( +
+ {presetsLoading && visibleCount === 0 && (
{t("modal.catalogLoading")}
)} - {tier !== "accounts" && rows.map(p => ( + {(searching || tier === "accounts") && accountMatches.length > 0 && ( + + {searching && groupHeading("accounts", accountMatches.length)} + {accountMatches.map(row => ( + + ))} + + )} + {presetGroups.map(group => group.rows.length === 0 ? null : ( + + {searching && groupHeading(group.tier, group.rows.length)} + {group.rows.map(p => ( // The reveal control is a SIBLING of the row button, never a child of it: the row // is already a
))} - {tier !== "accounts" && !presetsLoading && rows.length === 0 && ( -
{t("modal.noMatch")}
- )} - - {tier === "accounts" && accountRows.map(row => { - const status = accountStatus[row.id]; - const busy = busyProvider === row.id; - const loggedIn = !!status?.loggedIn; - const statusText = loggedIn - ? (status?.email ?? row.statusLabel ?? t("modal.accountLoggedIn")) - : (status?.error ?? row.statusLabel ?? t("modal.accountLoggedOut")); - // A first-time add is the one moment the operator has no other way in: - // the provider has no workspace panel yet, so without this the - // authorization URL is computed and never drawn. - const showHint = shouldShowLoginHint(row, busyProvider, loginHint); - return ( -
-
- {/* Account rows are providers too. A logo beside Cursor and a bare - tile beside Kiro reads as a bug, not as a distinction. */} - -
-
{row.label}
-
{statusText}
-
-
- {row.kind === "key" ? null : row.kind === "codex" ? ( - <> - {loggedIn && ( - {t("modal.accountManage")} - )} - {onLogin && ( - - )} - - ) : loggedIn ? ( - <> - {onManage && ( - - )} - {onLogin && ( - - )} - {busy && onCancelLogin && ( - - )} - {onLogout && !busy && ( - - )} - - ) : busy ? ( - onCancelLogin && - ) : ( - onLogin && - )} -
-
- {showHint && loginHint && ( - paste.onSubmit(row.id), - }, - } - : {})} - /> - )} -
- ); - })} - {tier === "accounts" && accountRows.length === 0 && !presetsLoading && ( + + ))} + {!presetsLoading && visibleCount === 0 && (
{t("modal.noMatch")}
)}
- {tier !== "accounts" && ( + {/* Browse copy. "Not listed?" is the escape hatch at the end of a list you read, + not a search result, so it stays out of the way while a query is live. */} + {!searching && tier !== "accounts" && ( )}
diff --git a/gui/src/components/provider-catalog/account-row-types.ts b/gui/src/components/provider-catalog/account-row-types.ts new file mode 100644 index 0000000000..e1a63d41ff --- /dev/null +++ b/gui/src/components/provider-catalog/account-row-types.ts @@ -0,0 +1,15 @@ +/** + * Shapes shared by the catalog's Accounts rows. They live here rather than in + * ProviderCatalog so CatalogAccountRow can import them without a cycle back through + * the component that renders it. + */ +export type AccountLoginStatus = { loggedIn: boolean; email?: string; error?: string; needsReauth?: boolean }; + +export type AccountLoginRow = { + id: string; + label: string; + kind: "oauth" | "key" | "codex"; + statusLabel?: string; + /** Optional deep-link for codex/account-pool management. */ + href?: string; +}; diff --git a/gui/src/components/provider-catalog/provider-presets.ts b/gui/src/components/provider-catalog/provider-presets.ts index 416725d512..d030bd158a 100644 --- a/gui/src/components/provider-catalog/provider-presets.ts +++ b/gui/src/components/provider-catalog/provider-presets.ts @@ -125,6 +125,90 @@ export function noteNeedsReveal(note: string | undefined): boolean { return !!note?.trim(); } +/** + * Queries that mean "a runtime on my own machine" without naming one. Resolved through + * `isLocalCatalogPreset` rather than a substring match, so `localhost` finds the Local + * group instead of matching every base URL that happens to contain the word. + */ +const LOCAL_QUERY_ALIASES = new Set(["local", "localhost", "ollama", "vllm", "lmstudio", "lm studio", "self-hosted", "selfhosted"]); + +/** + * Unified-search match for one preset. + * + * The haystack stays label + id, for the same reason `filterPresets` documents: a + * substring match on the adapter would return Ollama, vLLM, LM Studio, Groq, Cerebras + * and PackyCode for the query `openai`, and matching base URLs would return every local + * row for `localhost`. It widens in exactly two controlled ways instead — an *equality* + * match on the adapter id, so `cursor` finds Cursor while `openai` still does not match + * `openai-chat`, and the local aliases above. + */ +export function matchesCatalogQuery(preset: CatalogPreset, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + if (preset.label.toLowerCase().includes(q)) return true; + if (preset.id.toLowerCase().includes(q)) return true; + if (preset.adapter.toLowerCase() === q) return true; + return LOCAL_QUERY_ALIASES.has(q) && isLocalCatalogPreset(preset); +} + +/** + * Order matched rows WITHIN one group: exact id or label first, then a label/id prefix, + * then everything else in the order the caller already established — which carries the + * sponsor pin, then usage rank, then label. Deliberately never applied across groups: a + * paid sponsor sorted above free NVIDIA on the query `nim` reads as an ad slot, and the + * sponsor already has a badge and a pin inside its own group. + */ +export function sortCatalogMatches(presets: CatalogPreset[], query: string): CatalogPreset[] { + const q = query.trim().toLowerCase(); + if (!q) return presets; + const rank = (p: CatalogPreset): number => { + const label = p.label.toLowerCase(); + const id = p.id.toLowerCase(); + if (id === q || label === q) return 0; + if (label.startsWith(q) || id.startsWith(q)) return 1; + return 2; + }; + return presets + .map((preset, index) => ({ preset, index })) + .sort((a, b) => rank(a.preset) - rank(b.preset) || a.index - b.index) + .map(entry => entry.preset); +} + +/** + * Account-tab login rows are a different shape from presets and are built elsewhere, so + * they get their own label/id filter rather than a widened `filterPresets`. + * + * `pinnedId` is the provider with a login in flight. It survives a non-matching query on + * purpose: the row owns the authorization URL and the paste field, and unmounting it + * mid-login throws away what the user is in the middle of doing. + */ +export function filterAccountRows( + rows: readonly T[], + query: string, + pinnedId?: string | null, +): T[] { + const q = query.trim().toLowerCase(); + if (!q) return [...rows]; + return rows.filter(row => + row.id === pinnedId + || row.label.toLowerCase().includes(q) + || row.id.toLowerCase().includes(q)); +} + +/** + * Drop presets that a matched login row already represents. A login row and a preset can + * share an id (`openai`); the login row is the one that can actually be acted on, so it + * wins rather than the same provider appearing twice under two different tiers. + */ +export function dropPresetsCoveredByAccounts( + presets: CatalogPreset[], + accountRows: readonly { id: string }[], +): CatalogPreset[] { + if (accountRows.length === 0) return presets; + const covered = new Set(accountRows.map(row => row.id)); + return presets.filter(preset => !covered.has(preset.id)); +} + const SPONSOR_RANK: Record, number> = { main: 0, standard: 1 }; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d5280552e9..7434eddfa7 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1902,6 +1902,7 @@ export const de: Record = { "modal.tab.local": "Lokal", "modal.tab.paid": "Bezahlt", "modal.noteMore": "Vollständige Beschreibung anzeigen", + "modal.searchResults": "{count} Ergebnisse in {tiers}", "modal.accountsHint": "Hier ChatGPT/Codex, OAuth-Provider und API-Key-Konten anmelden. OpenAI ist eingebaut — anmelden statt erneut hinzufügen.", "modal.accountsCodexAuthLink": "Codex Auth", "modal.notListed": "Provider nicht dabei? Eigenen hinzufügen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 032d5aff46..c0f478b4a2 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1158,6 +1158,7 @@ export const en = { "modal.tab.local": "Local", "modal.tab.paid": "Paid", "modal.noteMore": "Show full description", + "modal.searchResults": "{count} results across {tiers}", "modal.accountsHint": "Sign in to ChatGPT/Codex, OAuth providers, and API-key accounts here. OpenAI is built in — log in rather than adding it again.", "modal.accountsCodexAuthLink": "Codex Auth", "modal.notListed": "Provider not listed? Add a custom one", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 322d496d6e..a749064352 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1131,6 +1131,7 @@ export const fr: Record = { "modal.tab.local": "Local", "modal.tab.paid": "Payant", "modal.noteMore": "Afficher la description complète", + "modal.searchResults": "{count} résultats dans {tiers}", "modal.accountsHint": "Connectez-vous ici à ChatGPT/Codex, aux fournisseurs OAuth et aux comptes avec clé API. OpenAI est intégré : connectez-vous au lieu de l’ajouter de nouveau.", "modal.accountsCodexAuthLink": "Codex Auth", "modal.notListed": "Fournisseur absent de la liste ? Ajoutez-en un personnalisé", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index f2ab45b8e9..e444aba8a4 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1071,6 +1071,7 @@ export const ja: Record = { "modal.tab.local": "ローカル", "modal.tab.paid": "有料", "modal.noteMore": "説明をすべて表示", + "modal.searchResults": "{tiers} で {count} 件", "modal.accountsHint": "ChatGPT/Codex、OAuth プロバイダー、API キーアカウントにここからサインインします。OpenAI は組み込み済み — 再度追加せずログインしてください。", "modal.accountsCodexAuthLink": "Codex 認証", "modal.notListed": "プロバイダーが載っていませんか? カスタムを追加", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a79196b3f0..edee1deedd 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1941,6 +1941,7 @@ export const ko: Record = { "modal.tab.local": "로컬", "modal.tab.paid": "유료", "modal.noteMore": "설명 전체 보기", + "modal.searchResults": "{tiers}에서 {count}개", "modal.accountsHint": "여기서 ChatGPT/Codex, OAuth, API 키 계정에 로그인하세요. OpenAI는 기본 제공 — 다시 추가하지 말고 로그인하세요.", "modal.accountsCodexAuthLink": "Codex 인증", "modal.notListed": "찾는 프로바이더가 없나요? 직접 추가", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 394e312abf..f7a6ea0dbf 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1126,6 +1126,7 @@ export const ru: Record = { "modal.tab.local": "Локальные", "modal.tab.paid": "Платные", "modal.noteMore": "Показать полное описание", + "modal.searchResults": "{count} результатов в {tiers}", "modal.accountsHint": "Здесь можно войти в аккаунты ChatGPT/Codex и OAuth-провайдеров, а также в аккаунты с API-ключами. Провайдер OpenAI уже встроен — просто войдите, а не добавляйте его заново.", "modal.accountsCodexAuthLink": "Аутентификация Codex", "modal.notListed": "Нет нужного провайдера? Добавьте свой", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index c6438767ea..0e642ca9ed 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1145,6 +1145,7 @@ export const tr: Record = { "modal.tab.local": "Yerel", "modal.tab.paid": "Ücretli", "modal.noteMore": "Açıklamanın tamamını göster", + "modal.searchResults": "{tiers} içinde {count} sonuç", "modal.accountsHint": "ChatGPT/Codex ve OAuth hesaplarına buradan giriş yapın.", "modal.accountsCodexAuthLink": "Codex Kimlik Doğrulaması", "modal.notListed": "Sağlayıcı listede yok mu? Özel sağlayıcı ekleyin", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 59e4ccfb47..17c1bd8ab5 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -924,6 +924,7 @@ export const zhTW: Record = { "modal.tab.local": "本地", "modal.tab.paid": "付費", "modal.noteMore": "查看完整說明", + "modal.searchResults": "在 {tiers} 中找到 {count} 個", "modal.accountsHint": "在此登入 ChatGPT/Codex、OAuth 與 API 金鑰帳號。OpenAI 為內建供應商 — 請登入,無需再次新增。", "modal.accountsCodexAuthLink": "Codex 認證", "modal.notListed": "沒有你要的供應商?新增自訂", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e4ecec59e8..61a2b3401b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1922,6 +1922,7 @@ export const zh: Record = { "modal.tab.local": "本地", "modal.tab.paid": "付费", "modal.noteMore": "查看完整说明", + "modal.searchResults": "在 {tiers} 中找到 {count} 个", "modal.accountsHint": "在此登录 ChatGPT/Codex、OAuth 与 API 密钥账户。OpenAI 为内置提供商 — 请登录,无需再次添加。", "modal.accountsCodexAuthLink": "Codex 认证", "modal.notListed": "没有你要的提供商?添加自定义", diff --git a/gui/src/styles/provider-catalog.css b/gui/src/styles/provider-catalog.css index e7776bfb99..53460445f6 100644 --- a/gui/src/styles/provider-catalog.css +++ b/gui/src/styles/provider-catalog.css @@ -29,6 +29,85 @@ border-bottom-color: var(--accent); } +/* Search mode: the strip stops being a tablist and becomes jump chips with counts, so + it loses the selected-underline vocabulary and gains a count badge. A chip with no + matches is disabled rather than hidden, so the strip does not reflow under the + pointer on every keystroke. */ +.provider-catalog-tabs--chips { + border-bottom: none; + flex-wrap: wrap; +} + +.provider-catalog-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 999px; +} + +.provider-catalog-chip:not(:disabled):hover { + color: var(--text); + border-color: var(--accent-ring); +} + +.provider-catalog-chip:disabled { + opacity: 0.45; + cursor: default; +} + +.provider-catalog-chip-count { + font-variant-numeric: tabular-nums; + font-size: var(--text-label); + color: var(--muted); +} + +/* Group heading inside the result list: a small caps label with a hairline running out + to the right, the way a dense list separates sections without adding another slab of + chrome. It scrolls with the content rather than sticking — `.modal-card` is a + translucent glass panel, so any opaque sticky bar shows a seam against it, and with + four short groups in a 360px list the chips above are the index anyway. */ +.provider-catalog-group-head { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 12px 2px 2px; + color: var(--muted); + font-size: 11px; + font-weight: var(--weight-semibold); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.provider-catalog-group-head:first-child { + padding-top: 2px; +} + +.provider-catalog-group-head::after { + content: ""; + flex: 1 1 auto; + height: 1px; + background: var(--border); +} + +.provider-catalog-group-head:focus-visible { + outline: 2px solid var(--accent-ring); + outline-offset: 3px; + border-radius: var(--radius-xs); +} + +/* The count is data, not a label: tabular figures, no small caps, and it sits before + the rule so the eye reads "FREE 1 ————" as one unit. */ +.provider-catalog-group-count { + font-variant-numeric: tabular-nums; + font-weight: var(--weight-normal, 400); + letter-spacing: 0; + text-transform: none; + opacity: 0.75; +} + .provider-catalog-accounts-hint { padding: 2px 2px 0; } diff --git a/gui/tests/provider-catalog-search.test.tsx b/gui/tests/provider-catalog-search.test.tsx new file mode 100644 index 0000000000..c4e067e3bc --- /dev/null +++ b/gui/tests/provider-catalog-search.test.tsx @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import AddProviderModal from "../src/components/AddProviderModal"; + +/** + * Unified search replaces browse mode instead of filtering inside one tab, and the tab + * strip becomes jump chips rather than moving the selection. These pin the three things + * that make that safe rather than merely different: the selected tab survives a search + * that matches nothing in it, a strip click does not throw the query away, and a login + * already in flight is never unmounted by a query that does not happen to match it. + */ + +const PRESETS = [ + { id: "cerebras", label: "Cerebras", adapter: "openai-completions", baseUrl: "https://api.cerebras.ai/v1", auth: "key" }, + { id: "nvidia", label: "NVIDIA NIM", adapter: "openai-chat", baseUrl: "https://integrate.api.nvidia.com/v1", auth: "key", freeTier: true }, +]; + +const ACCOUNT_ROWS = [ + { id: "cursor", label: "Cursor", kind: "oauth" as const }, + { id: "anthropic", label: "Anthropic (Claude)", kind: "oauth" as const }, +]; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + previous = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previous; + originalFetch = globalThis.fetch; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/provider-presets") return Response.json({ providers: PRESETS }); + if (url.pathname === "/api/oauth/providers") return Response.json({ providers: [] }); + if (url.pathname === "/api/usage") return Response.json({ providers: [] }); + return Response.json({}); + }, + }); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +type ModalExtras = Partial[0]>; + +async function mount(extras: ModalExtras = {}) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + {}} onAdded={() => {}} {...extras} /> + , + ); + }); + await act(async () => { await new Promise(r => setTimeout(r, 60)); }); +} + +function search(): HTMLInputElement { + return win.document.querySelector(".provider-catalog-search") as unknown as HTMLInputElement; +} + +async function type(value: string) { + const input = search(); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")?.set; + setter?.call(input, value); + input.dispatchEvent(new win.Event("input", { bubbles: true }) as never); + }); +} + +function chips(): HTMLButtonElement[] { + return [...win.document.querySelectorAll(".provider-catalog-chip")] as unknown as HTMLButtonElement[]; +} + +function selectedTabs(): string[] { + return [...win.document.querySelectorAll('[role="tab"][aria-selected="true"]')].map(el => el.textContent ?? ""); +} + +test("a query that matches nothing on the selected tab does not move the tab", async () => { + await mount(); + expect(selectedTabs()).toEqual(["Paid"]); + + // NVIDIA is a Free row; Paid has no hit at all. + await type("nvidia"); + + // Search mode: the strip is chips, so nothing is announced as a selected tab, and the + // Free group is on screen without the Paid tab having been stolen. + expect(selectedTabs()).toEqual([]); + expect(chips().length).toBe(4); + expect(win.document.querySelector(".provider-catalog-rows")?.textContent).toContain("NVIDIA NIM"); + + // Clearing restores the tab the user actually chose. + await type(""); + expect(selectedTabs()).toEqual(["Paid"]); +}); + +test("clicking the strip during a search does not throw the query away", async () => { + await mount(); + await type("nvidia"); + const free = chips().find(chip => (chip.textContent ?? "").startsWith("Free")); + expect(free?.disabled).toBe(false); + await act(async () => { free?.click(); }); + expect(search().value).toBe("nvidia"); +}); + +test("a login in flight survives a query that does not match its row", async () => { + await mount({ + accountRows: ACCOUNT_ROWS, + accountBusy: "cursor", + accountLoginHint: { provider: "cursor", url: "https://example.com/authorize" }, + }); + await type("nvidia"); + const rows = win.document.querySelector(".provider-catalog-rows")?.textContent ?? ""; + // Cursor does not match "nvidia". It stays because it owns the authorization URL and + // the paste field, and unmounting it mid-login throws away the login in progress. + expect(rows).toContain("Cursor"); + expect(rows).not.toContain("Anthropic"); +}); diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index d078710d5e..71729e567c 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -301,7 +301,7 @@ single forms, and the shell pattern is the part worth keeping stable: | Storage | Rail plus cleanup and trash detail (`gui/src/components/storage-workspace/`). | | Subagents | Featured-roster selection workspace (`gui/src/components/subagents-workspace/`). | | Combos | Rail, detail panel, and an add flow (`gui/src/components/ComboWorkspace.tsx`). | -| Add provider | Catalog browser plus form and OAuth panes (`gui/src/components/provider-catalog/`, `gui/src/components/AddProviderModal.tsx`). Catalog tabs are Accounts, Free, Local and Paid; local-auth and loopback presets browse under Local while workspace pricing tiers stay three-way. The tab strip wraps within narrow modals. Every nonempty note has a full-text button alongside its clamped preview. The native note dialog closes during teardown and restores focus to that button. | +| Add provider | Catalog browser plus form and OAuth panes (`gui/src/components/provider-catalog/`, `gui/src/components/AddProviderModal.tsx`). The catalog browses four tabs — Accounts, Free, Local, Paid — where Local is a catalog-only bucket peeled out of `bucketPresets` after `presetTier` has classified; the workspace `providerTier` stays three-way, so the rail, the free-paid sort and the Free count still treat a local runtime as free. Search sits above the tabs and reaches every tab at once: while a query is live the list renders all four groups with headings and the strip becomes jump chips with counts rather than a tablist, because moving the selected tab would change the row kind under the user (a preset-select button becomes a login row). The tab strip wraps within narrow modals. Every nonempty note has a full-text button so narrow rows never hide content permanently; the native note dialog closes during teardown and restores focus to its trigger. Provider notes clamp to two lines and open in full in a stacked native `` owned by `AddProviderModal`, which also owns the search text so its `window` Escape handler can unwind popup, then query, then dialog. | | Codex accounts | Account pool cards, add-account flow, switch and reset modals (`gui/src/components/CodexAccountPool.tsx`, `gui/src/components/AddCodexAccountModal.tsx`), plus the generic account-targeting picker opt-in on `gui/src/pages/codex-set-multiauth.tsx`. Add/delete/login completion is projected to one boolean before presentation; pending catalog work is a warning, not a failed account mutation. | | Dashboard overview | Overview, Providers, and Models tabs at the page level (`gui/src/pages/Dashboard.tsx`), the 30-day token and coverage stats in the overview head (`gui/src/pages/dashboard-overview-head.tsx`), and the effort-cap, injection, maintenance, sidecar, and memory panels below it (`gui/src/pages/dashboard-overview-panels.tsx`). | diff --git a/tests/gui/provider-workspace-data.test.ts b/tests/gui/provider-workspace-data.test.ts index 8c6b0028ae..48502e5daf 100644 --- a/tests/gui/provider-workspace-data.test.ts +++ b/tests/gui/provider-workspace-data.test.ts @@ -35,6 +35,10 @@ import { filterPresets, presetTier, noteNeedsReveal, + matchesCatalogQuery, + sortCatalogMatches, + filterAccountRows, + dropPresetsCoveredByAccounts, type CatalogPreset, } from "../../gui/src/components/provider-catalog/provider-presets"; import { isLocalProvider, providerKind } from "../../gui/src/provider-workspace/kind"; @@ -575,6 +579,61 @@ describe("add-provider catalog presets (WP050a)", () => { expect(noteNeedsReveal(" ")).toBe(false); }); + test("unified search widens by adapter EQUALITY, never by adapter prefix or base URL", () => { + const ollama = preset({ id: "ollama", label: "Ollama (local)", auth: "local", baseUrl: "http://localhost:11434/v1" }); + const cursor = preset({ id: "cursor", label: "Cursor", adapter: "cursor", baseUrl: "https://api.cursor.com/v1" }); + + // The whole reason the haystack is not the adapter: openai-chat is the adapter of + // Ollama, vLLM, LM Studio, Groq, Cerebras and PackyCode, so a prefix match on + // "openai" would return half the catalog. + expect(matchesCatalogQuery(ollama, "openai")).toBe(false); + expect(matchesCatalogQuery(ollama, "openai-chat")).toBe(true); + expect(matchesCatalogQuery(cursor, "cursor")).toBe(true); + + // Base URLs stay out of the haystack: otherwise "api" returns most of the Paid tab. + expect(matchesCatalogQuery(cursor, "api.cursor.com")).toBe(false); + + // Aliases reach the Local group through the classifier, not through a substring. + expect(matchesCatalogQuery(ollama, "localhost")).toBe(true); + expect(matchesCatalogQuery(ollama, "self-hosted")).toBe(true); + expect(matchesCatalogQuery(cursor, "localhost")).toBe(false); + + // Label and id remain the ordinary path, case-insensitively. + expect(matchesCatalogQuery(cursor, "CURS")).toBe(true); + expect(matchesCatalogQuery(cursor, "")).toBe(true); + }); + + test("search ranking is exact, then prefix, then the order the caller already chose", () => { + // Incoming order carries the sponsor pin, then usage rank, then label — this must + // only reorder for exact and prefix hits, never re-rank the tail. + const rows = [ + preset({ id: "groq-cloud", label: "Groq Cloud" }), + preset({ id: "xyz", label: "Not a groq thing" }), + preset({ id: "groq", label: "Groq" }), + ]; + expect(sortCatalogMatches(rows, "groq").map(p => p.id)).toEqual(["groq", "groq-cloud", "xyz"]); + // An empty query is browse mode: the caller's order is returned untouched. + expect(sortCatalogMatches(rows, "").map(p => p.id)).toEqual(["groq-cloud", "xyz", "groq"]); + }); + + test("a login in flight survives a query that does not match it", () => { + const rows = [ + { id: "cursor", label: "Cursor" }, + { id: "anthropic", label: "Anthropic (Claude)" }, + ]; + expect(filterAccountRows(rows, "claude").map(r => r.id)).toEqual(["anthropic"]); + // The busy row owns the authorization URL and the paste field; unmounting it + // mid-login throws away what the user is in the middle of doing. + expect(filterAccountRows(rows, "claude", "cursor").map(r => r.id)).toEqual(["cursor", "anthropic"]); + expect(filterAccountRows(rows, "").map(r => r.id)).toEqual(["cursor", "anthropic"]); + }); + + test("a provider that already has a login row is not also listed as a preset", () => { + const presets = [preset({ id: "openai", label: "OpenAI" }), preset({ id: "groq", label: "Groq" })]; + expect(dropPresetsCoveredByAccounts(presets, [{ id: "openai" }]).map(p => p.id)).toEqual(["groq"]); + expect(dropPresetsCoveredByAccounts(presets, []).map(p => p.id)).toEqual(["openai", "groq"]); + }); + }); describe("provider kind classification (WP080a)", () => {