From 68e6b028d765299dc609b42249e92a066da04f64 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 11:45:18 +0900 Subject: [PATCH 1/2] feat(gui): make the add-provider search reach every tab at once The search box sat under the tab strip and filtered only the selected tab, and a tab click wiped whatever had been typed. Someone looking for Claude on the Free tab saw nothing and had no way to find out it was one tab over. The input now sits above the tabs and matches across all four. Search mode replaces browse mode rather than filtering inside it. While a query is live every group renders - Accounts, Free, Local, Paid - each under a small-caps heading with its count, and the selected tab is frozen rather than moved. Moving it would not merely change which rows are listed: it changes the kind of row, because a preset-select button becomes a login row with Log in and Add account buttons. The strip drops role=tablist for that duration and becomes jump chips with counts, disabled at zero, that scroll to a group without changing the tab. Clearing the query restores the tab the user chose and returns the list to the top. Account login rows are searchable too, and the row with a login in flight is pinned into the results regardless of the query: it owns the authorization URL and the paste field, and unmounting it mid-login throws away what the user is doing. A preset sharing an id with a matched login row is dropped so the same provider cannot appear twice. The haystack stays label and id. Matching adapters 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, and a small alias set resolved through the local classifier. Ranking is per group only - exact, then prefix, then the caller order that already carries the sponsor pin and usage rank - because a paid sponsor sorted above free NVIDIA on the query nim reads as an ad slot. The search text is owned by AddProviderModal because its Escape listener is on window and registers before a child's, so Escape unwinds one layer at a time: the note popup, then a live query, then the dialog. --- .../040_r1_unified_search.md | 25 ++ .../evidence/wp4-browse-mode.png | Bin 0 -> 22008 bytes .../evidence/wp4-search-accounts.png | Bin 0 -> 22030 bytes .../evidence/wp4-search-groups.png | Bin 0 -> 35919 bytes .../src/content/docs/guides/web-dashboard.md | 2 +- .../content/docs/ko/guides/web-dashboard.md | 2 +- .../content/docs/ru/guides/web-dashboard.md | 2 +- .../docs/zh-cn/guides/web-dashboard.md | 2 +- .../docs/zh-tw/guides/web-dashboard.md | 2 +- gui/src/components/AddProviderModal.tsx | 19 +- .../provider-catalog/CatalogAccountRow.tsx | 130 +++++++ .../provider-catalog/ProviderCatalog.tsx | 348 +++++++++++------- .../provider-catalog/account-row-types.ts | 15 + .../provider-catalog/provider-presets.ts | 84 +++++ gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/styles/provider-catalog.css | 79 ++++ gui/tests/provider-catalog-search.test.tsx | 147 ++++++++ structure/gui-and-management-api.md | 2 +- tests/gui/provider-workspace-data.test.ts | 59 +++ 27 files changed, 783 insertions(+), 144 deletions(-) create mode 100644 devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-browse-mode.png create mode 100644 devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-search-accounts.png create mode 100644 devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-search-groups.png create mode 100644 gui/src/components/provider-catalog/CatalogAccountRow.tsx create mode 100644 gui/src/components/provider-catalog/account-row-types.ts create mode 100644 gui/tests/provider-catalog-search.test.tsx 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/evidence/wp4-browse-mode.png b/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-browse-mode.png new file mode 100644 index 0000000000000000000000000000000000000000..d311c5d86f994a57ae5e668563dbefb394834268 GIT binary patch literal 22008 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

t0G^0FdAu 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cf77ac92b8c24b38bac1d50c84efb51373f4cfc1 GIT binary patch literal 35919 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 Z-OUi|+f)2G_U`xDn3#=2f9!Mj{{dsf83q6V literal 0 HcmV?d00001 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 1198c9b5da..15d9c4d56b 100644 --- a/gui/src/components/provider-catalog/provider-presets.ts +++ b/gui/src/components/provider-catalog/provider-presets.ts @@ -133,6 +133,90 @@ export function noteNeedsReveal(note: string | undefined): boolean { return (note?.trim().length ?? 0) > NOTE_CLAMP_CHARS; } +/** + * 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 11df684e98..c764126a4b 100644 --- a/gui/src/styles/provider-catalog.css +++ b/gui/src/styles/provider-catalog.css @@ -28,6 +28,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 c6b35fbad8..95688f5c9f 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`). | +| 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). Long 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 1cc373dd8b..554a319e37 100644 --- a/tests/gui/provider-workspace-data.test.ts +++ b/tests/gui/provider-workspace-data.test.ts @@ -36,6 +36,10 @@ import { presetTier, noteNeedsReveal, NOTE_CLAMP_CHARS, + matchesCatalogQuery, + sortCatalogMatches, + filterAccountRows, + dropPresetsCoveredByAccounts, type CatalogPreset, } from "../../gui/src/components/provider-catalog/provider-presets"; import { isLocalProvider, providerKind } from "../../gui/src/provider-workspace/kind"; @@ -583,6 +587,61 @@ describe("add-provider catalog presets (WP050a)", () => { expect(noteNeedsReveal(`${"x".repeat(NOTE_CLAMP_CHARS)}${" ".repeat(40)}`)).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)", () => { From 7c41b3872dd6153719a6ca61acdf19171fc6f305 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 12:20:21 +0900 Subject: [PATCH 2/2] docs(devlog): record the provider-catalog stack delivery and its CI evidence Four dependent PRs with their exact heads and live CI run ids, plus the two traps that make a green head look red: a cancelled duplicate run leaves a FAILURE ci row attached to the same SHA as the live green run, and a cancelled required check is not a passing one - all three enforce-target attempts on #4328 were cancelled by concurrency, which left the PR UNSTABLE until one was re-run. --- .../060_delivery_record.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 devlog/_plan/260912_provider_catalog_unified_search/060_delivery_record.md 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.