Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<button>`. And the
> threshold shipped at **90**, not 120: at the modal width two lines hold roughly 110
> characters minus the adapter chip, so 120 left notes in the 95-111 range visually
> clamped with no way to read the rest. Erring short only costs a reveal on a row that
> did not strictly need one.

**Popup.** A sibling overlay with the same shape as `OAuthTosWarningModal`: a
`role="dialog" aria-modal="true"` card rendered next to the add-provider overlay rather
than inside it, holding the provider label, the adapter chip, the full note, and a close
Expand All @@ -30,6 +37,18 @@ button. The argument for a popup over grok's inline disclosure is in `015`.
`if (e.key === "Escape" && !oauthTosPending) onClose()`. The note popup joins that
guard, so Escape closes the note first and the add-provider modal second.

## The cascade trap, hit again

`gui/src/styles/provider-catalog.css` is `@import`ed at the **top** of `styles.css`,
while `.link-btn` is declared far below it at equal specificity. A bare
`.provider-catalog-note-more` therefore loses `background`, `border`, `padding` and
`font-size` to `.link-btn`, while the `:has()` rule that strips the row's bottom border
does win — so the row opened at the bottom and the reveal rendered as a full-width
underlined link floating between two rows. Vite HMR hides this by injecting the edited
file last; a production bundle does not. The rule is qualified as
`.link-btn.provider-catalog-note-more`, exactly as
`.list-row.provider-catalog-account-row--waiting` already had to be.

## Verification (remote CI only)

- pure: the overflow predicate is true for the opencode-free note and false for
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 21 additions & 3 deletions gui/src/components/AddProviderModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { usageSummary30dResourceKey } from "../usage-summary-resource";
import { useEffect, useMemo, useReducer, useRef } from "react";
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
import { IconX } from "../icons";
import { useT } from "../i18n/shared";
import { useKeyedClientResource } from "../client-resource";
Expand All @@ -12,6 +12,7 @@ import {
import { oauthTosRisk } from "../oauth-tos-risk";
import OAuthTosWarningModal from "./OAuthTosWarningModal";
import ProviderCatalog from "./provider-catalog/ProviderCatalog";
import ProviderNoteModal from "./provider-catalog/ProviderNoteModal";
import type { AccountLoginRow, AccountLoginStatus } from "./provider-catalog/ProviderCatalog";
import type { CatalogPreset } from "./provider-catalog/provider-presets";
import type { CatalogLoginHint } from "./provider-catalog/login-hint-visibility";
Expand Down Expand Up @@ -62,6 +63,9 @@ export default function AddProviderModal({
const aliveRef = useRef(true);
const previousFocusRef = useRef<HTMLElement | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// 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<Preset | null>(null);

const oauthPoll = useKeyedClientResource(
`add-provider-oauth:${apiBase}`,
Expand Down Expand Up @@ -125,11 +129,14 @@ export default function AddProviderModal({

useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !oauthTosPending) onClose();
// This listener is on `window` and does not read `defaultPrevented`, so a native
// <dialog> 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();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, oauthTosPending]);
}, [onClose, oauthTosPending, notePreset]);

const presetDescription = (candidate: Preset): string | undefined => {
const key = codexPresetDescriptionKey(candidate);
Expand Down Expand Up @@ -258,6 +265,7 @@ export default function AddProviderModal({
initialTier={initialTier}
onSelectPreset={p => choosePreset(p)}
onSelectCustom={() => choosePreset(fallbackPresets[0]!)}
onShowNote={p => setNotePreset(p)}
accountRows={accountRows}
accountStatus={accountStatus}
busyProvider={accountBusy}
Expand Down Expand Up @@ -339,6 +347,16 @@ export default function AddProviderModal({
}}
/>
)}
{notePreset?.note && (
<ProviderNoteModal
key={notePreset.id}
providerId={notePreset.id}
label={notePreset.label}
adapter={notePreset.adapter}
note={notePreset.note}
onClose={() => setNotePreset(null)}
/>
)}
</>
);
}
48 changes: 33 additions & 15 deletions gui/src/components/provider-catalog/ProviderCatalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
bucketPresets,
pinSponsors,
filterPresets,
noteNeedsReveal,
type CatalogPreset,
type CatalogTier,
} from "./provider-presets";
Expand Down Expand Up @@ -50,6 +51,7 @@ export default function ProviderCatalog({
initialTier = "free",
onSelectPreset,
onSelectCustom,
onShowNote,
accountRows = EMPTY_ACCOUNT_ROWS,
accountStatus = EMPTY_ACCOUNT_STATUS,
busyProvider = null,
Expand All @@ -66,6 +68,8 @@ export default function ProviderCatalog({
initialTier?: CatalogTier;
onSelectPreset: (preset: CatalogPreset) => void;
onSelectCustom: () => void;
/** Open the full-note popup for a row whose note is clamped. Owned by the modal. */
onShowNote?: (preset: CatalogPreset) => void;
/** Accounts-tab login rows; empty (default) degrades to preset-only rendering. */
accountRows?: AccountLoginRow[];
accountStatus?: Record<string, AccountLoginStatus>;
Expand Down Expand Up @@ -164,21 +168,35 @@ export default function ProviderCatalog({
<div className="muted text-control provider-catalog-empty">{t("modal.catalogLoading")}</div>
)}
{tier !== "accounts" && rows.map(p => (
<button type="button" key={p.id} className="list-row" onClick={() => onSelectPreset(p)}>
{/*
The one list a user reads to CHOOSE a provider, and until now the only
provider surface with no marks at all. `CatalogPreset.id` is the
registry id, so this reuses `providerIconSrc` and, with it, the
mask/plate decision the rail already owns -- a mark cannot be legible
in the workspace and invisible here.
*/}
<ProviderIcon name={p.id} adapter={p.adapter} cls="provider-icon provider-icon-sm" />
<div>
<div className="title">{p.label}</div>
<div className="sub"><code className="chip">{p.adapter}</code>{p.note ? ` · ${p.note}` : ""}</div>
</div>
<div className="provider-catalog-badges">{badges(p)}</div>
</button>
// The reveal control is a SIBLING of the row button, never a child of it: the row
// is already a <button>, and a button inside a button is invalid HTML that the
// parser may hoist out of the row -- at which point `stopPropagation` never runs.
<div key={p.id} className="provider-catalog-row-wrap">
<button type="button" className="list-row" onClick={() => onSelectPreset(p)}>
{/*
The one list a user reads to CHOOSE a provider, and until now the only
provider surface with no marks at all. `CatalogPreset.id` is the
registry id, so this reuses `providerIconSrc` and, with it, the
mask/plate decision the rail already owns -- a mark cannot be legible
in the workspace and invisible here.
*/}
<ProviderIcon name={p.id} adapter={p.adapter} cls="provider-icon provider-icon-sm" />
<div>
<div className="title">{p.label}</div>
<div className="sub"><code className="chip">{p.adapter}</code>{p.note ? ` · ${p.note}` : ""}</div>
</div>
<div className="provider-catalog-badges">{badges(p)}</div>
</button>
{onShowNote && noteNeedsReveal(p.note) && (
<button
type="button"
className="link-btn provider-catalog-note-more"
onClick={() => onShowNote(p)}
>
{t("modal.noteMore")}
</button>
)}
</div>
))}
{tier !== "accounts" && !presetsLoading && rows.length === 0 && (
<div className="muted text-control provider-catalog-empty">{t("modal.noMatch")}</div>
Expand Down
84 changes: 84 additions & 0 deletions gui/src/components/provider-catalog/ProviderNoteModal.tsx
Original file line number Diff line number Diff line change
@@ -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 `<dialog>` + `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<HTMLDialogElement>(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 });
};
}, []);
Comment on lines +38 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore focus after closing the note dialog

When the popup is dismissed via Escape, the backdrop, or either Close button, onClose immediately unmounts the still-open native dialog; no path calls dialog.close() and the parent retains no reference to the reveal button. Because the focused control is removed with the dialog, keyboard focus falls back to the document instead of returning to “Show full description,” so keyboard users lose their place in the catalog. Close the dialog during teardown and explicitly restore focus to the triggering reveal after the popup state clears.

AGENTS.md reference: gui/AGENTS.md:L31-L34

Useful? React with 👍 / 👎.


// Native <dialog> 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 (
<dialog
ref={dialogRef}
aria-labelledby={titleId}
aria-describedby={bodyId}
className="modal-overlay"
onCancel={handleCancel}
>
<button type="button" className="modal-backdrop-dismiss" aria-label={t("common.close")} tabIndex={-1} onClick={onClose} />
<div className="modal-card provider-note-card" onClick={e => e.stopPropagation()}>
<div className="modal-head">
<h3 id={titleId} className="provider-note-title">
<ProviderIcon name={providerId} adapter={adapter} cls="provider-icon provider-icon-sm" />
{label}
</h3>
<button type="button" className="btn btn-ghost btn-icon" aria-label={t("common.close")} onClick={onClose}>
<IconX />
</button>
</div>
<div id={bodyId} className="provider-note-body">
<code className="chip">{adapter}</code>
<p className="modal-desc provider-note-text">{note}</p>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>{t("common.close")}</button>
</div>
</div>
</dialog>
);
}
6 changes: 6 additions & 0 deletions gui/src/components/provider-catalog/provider-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NonNullable<CatalogPreset["sponsor"]>, number> = { main: 0, standard: 1 };

/**
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1901,6 +1901,7 @@ export const de: Record<TKey, string> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,7 @@ export const fr: Record<TKey, string> = {
"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é",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1070,6 +1070,7 @@ export const ja: Record<TKey, string> = {
"modal.tab.free": "無料",
"modal.tab.local": "ローカル",
"modal.tab.paid": "有料",
"modal.noteMore": "説明をすべて表示",
"modal.accountsHint": "ChatGPT/Codex、OAuth プロバイダー、API キーアカウントにここからサインインします。OpenAI は組み込み済み — 再度追加せずログインしてください。",
"modal.accountsCodexAuthLink": "Codex 認証",
"modal.notListed": "プロバイダーが載っていませんか? カスタムを追加",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1940,6 +1940,7 @@ export const ko: Record<TKey, string> = {
"modal.tab.free": "무료",
"modal.tab.local": "로컬",
"modal.tab.paid": "유료",
"modal.noteMore": "설명 전체 보기",
"modal.accountsHint": "여기서 ChatGPT/Codex, OAuth, API 키 계정에 로그인하세요. OpenAI는 기본 제공 — 다시 추가하지 말고 로그인하세요.",
"modal.accountsCodexAuthLink": "Codex 인증",
"modal.notListed": "찾는 프로바이더가 없나요? 직접 추가",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,7 @@ export const ru: Record<TKey, string> = {
"modal.tab.free": "Бесплатные",
"modal.tab.local": "Локальные",
"modal.tab.paid": "Платные",
"modal.noteMore": "Показать полное описание",
"modal.accountsHint": "Здесь можно войти в аккаунты ChatGPT/Codex и OAuth-провайдеров, а также в аккаунты с API-ключами. Провайдер OpenAI уже встроен — просто войдите, а не добавляйте его заново.",
"modal.accountsCodexAuthLink": "Аутентификация Codex",
"modal.notListed": "Нет нужного провайдера? Добавьте свой",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,7 @@ export const tr: Record<TKey, string> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,7 @@ export const zhTW: Record<TKey, string> = {
"modal.tab.free": "免費",
"modal.tab.local": "本地",
"modal.tab.paid": "付費",
"modal.noteMore": "查看完整說明",
"modal.accountsHint": "在此登入 ChatGPT/Codex、OAuth 與 API 金鑰帳號。OpenAI 為內建供應商 — 請登入,無需再次新增。",
"modal.accountsCodexAuthLink": "Codex 認證",
"modal.notListed": "沒有你要的供應商?新增自訂",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1921,6 +1921,7 @@ export const zh: Record<TKey, string> = {
"modal.tab.free": "免费",
"modal.tab.local": "本地",
"modal.tab.paid": "付费",
"modal.noteMore": "查看完整说明",
"modal.accountsHint": "在此登录 ChatGPT/Codex、OAuth 与 API 密钥账户。OpenAI 为内置提供商 — 请登录,无需再次添加。",
"modal.accountsCodexAuthLink": "Codex 认证",
"modal.notListed": "没有你要的提供商?添加自定义",
Expand Down
Loading
Loading