diff --git a/devlog/_plan/260912_provider_catalog_unified_search/030_r2_note_clamp.md b/devlog/_plan/260912_provider_catalog_unified_search/030_r2_note_clamp.md index ae9ea4dd96..40c2e3352a 100644 --- a/devlog/_plan/260912_provider_catalog_unified_search/030_r2_note_clamp.md +++ b/devlog/_plan/260912_provider_catalog_unified_search/030_r2_note_clamp.md @@ -21,6 +21,13 @@ layout read: `scrollHeight > clientHeight` would need a ref per row plus a resiz observer and would make the decision untestable without a DOM. The threshold lives in `provider-presets.ts` as a named constant with its own unit test. +> Superseded twice. `016` replaced the nested button with a sibling inside +> `.provider-catalog-row-wrap`, because `.list-row` is already a ` + // The reveal control is a SIBLING of the row button, never a child of it: the row + // is already a + {onShowNote && noteNeedsReveal(p.note) && ( + + )} + ))} {tier !== "accounts" && !presetsLoading && rows.length === 0 && (
{t("modal.noMatch")}
diff --git a/gui/src/components/provider-catalog/ProviderNoteModal.tsx b/gui/src/components/provider-catalog/ProviderNoteModal.tsx new file mode 100644 index 0000000000..cbaaf3f0fe --- /dev/null +++ b/gui/src/components/provider-catalog/ProviderNoteModal.tsx @@ -0,0 +1,84 @@ +/** + * Full-text popup for a catalog row's provider note. + * + * The catalog clamps a note to two lines, because a few of them are paragraphs: the + * `opencode-free` note is ~1100 characters and `meta-muse` is longer still, and at the + * modal width either one fills the entire 360px scroll viewport, so the row it belongs + * to becomes the only row a user can see. + * + * Native `` + `showModal()`, deliberately the same shape as + * `OAuthTosWarningModal`: it gives focus trapping and a backdrop for free, and — the + * part a hand-rolled overlay does not get — it restores focus to the control that + * opened it when it closes. It is rendered as a sibling of the add-provider overlay + * rather than inside it, so there is no dialog nested in a dialog's DOM. + */ +import { useCallback, useEffect, useId, useRef } from "react"; +import { useT } from "../../i18n/shared"; +import { IconX } from "../../icons"; +import { ProviderIcon } from "../provider-workspace/ProviderRail"; + +export default function ProviderNoteModal({ + providerId, + label, + adapter, + note, + onClose, +}: { + providerId: string; + label: string; + adapter: string; + note: string; + onClose: () => void; +}) { + const t = useT(); + const titleId = useId(); + const bodyId = useId(); + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + const trigger = document.activeElement as HTMLElement | null; + if (dialog && !dialog.open) dialog.showModal(); + return () => { + if (dialog?.open) dialog.close(); + if (trigger?.isConnected) trigger.focus({ preventScroll: true }); + }; + }, []); + + // Native fires "cancel" on Escape — forward it so this popup closes first + // and the add-provider modal underneath stays open. + const handleCancel = useCallback((e: React.SyntheticEvent) => { + e.preventDefault(); + onClose(); + }, [onClose]); + + return ( + + + +
+ {adapter} +

{note}

+
+
+ +
+ +
+ ); +} diff --git a/gui/src/components/provider-catalog/provider-presets.ts b/gui/src/components/provider-catalog/provider-presets.ts index 93119b85fa..416725d512 100644 --- a/gui/src/components/provider-catalog/provider-presets.ts +++ b/gui/src/components/provider-catalog/provider-presets.ts @@ -119,6 +119,12 @@ export function filterPresets(presets: CatalogPreset[], query: string): CatalogP return presets.filter(p => p.label.toLowerCase().includes(q) || p.id.toLowerCase().includes(q)); } +/** Every nonempty note has a full-text route: rendered clipping depends on width, + * adapter chips and badges, so no character threshold can safely hide the control. */ +export function noteNeedsReveal(note: string | undefined): boolean { + return !!note?.trim(); +} + const SPONSOR_RANK: Record, number> = { main: 0, standard: 1 }; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8153debace..d5280552e9 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1901,6 +1901,7 @@ export const de: Record = { "modal.tab.free": "Kostenlos", "modal.tab.local": "Lokal", "modal.tab.paid": "Bezahlt", + "modal.noteMore": "Vollständige Beschreibung anzeigen", "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 c5abf0e2cb..032d5aff46 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1157,6 +1157,7 @@ export const en = { "modal.tab.free": "Free", "modal.tab.local": "Local", "modal.tab.paid": "Paid", + "modal.noteMore": "Show full description", "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 09ff78305a..322d496d6e 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1130,6 +1130,7 @@ export const fr: Record = { "modal.tab.free": "Gratuit", "modal.tab.local": "Local", "modal.tab.paid": "Payant", + "modal.noteMore": "Afficher la description complète", "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 5cb65d9f07..f2ab45b8e9 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1070,6 +1070,7 @@ export const ja: Record = { "modal.tab.free": "無料", "modal.tab.local": "ローカル", "modal.tab.paid": "有料", + "modal.noteMore": "説明をすべて表示", "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 f7e8c259a0..a79196b3f0 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1940,6 +1940,7 @@ export const ko: Record = { "modal.tab.free": "무료", "modal.tab.local": "로컬", "modal.tab.paid": "유료", + "modal.noteMore": "설명 전체 보기", "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 2a6b3dc075..394e312abf 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1125,6 +1125,7 @@ export const ru: Record = { "modal.tab.free": "Бесплатные", "modal.tab.local": "Локальные", "modal.tab.paid": "Платные", + "modal.noteMore": "Показать полное описание", "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 c1a7d958f6..c6438767ea 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1144,6 +1144,7 @@ export const tr: Record = { "modal.tab.free": "Ücretsiz", "modal.tab.local": "Yerel", "modal.tab.paid": "Ücretli", + "modal.noteMore": "Açıklamanın tamamını göster", "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 7801fdf277..59e4ccfb47 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -923,6 +923,7 @@ export const zhTW: Record = { "modal.tab.free": "免費", "modal.tab.local": "本地", "modal.tab.paid": "付費", + "modal.noteMore": "查看完整說明", "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 cccbca8dcd..e4ecec59e8 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1921,6 +1921,7 @@ export const zh: Record = { "modal.tab.free": "免费", "modal.tab.local": "本地", "modal.tab.paid": "付费", + "modal.noteMore": "查看完整说明", "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 ef2bbe67dd..e7776bfb99 100644 --- a/gui/src/styles/provider-catalog.css +++ b/gui/src/styles/provider-catalog.css @@ -56,6 +56,107 @@ padding: 8px; } +/* A preset row and its note-reveal control. The control has to be a SIBLING of the row + button rather than a child, because the row is itself a