Skip to content

Commit 56ffd7a

Browse files
committed
feat(api-tab): give the page back its scrollbar
The API tab locked itself to the viewport on any desktop above 1100x700 and then handed vertical scroll to four nested `contain` scrollers. Storage removed that same arrangement for the same reason: the wheel never reached the document, so content below the fold was reachable only by finding the right inner column first. The media query is gone with no replacement — the base rules already provide width, padding, grid, and natural height. The model catalog is now the one capped region, using the rule Usage settled on: bounded height, sticky header, and `overscroll-behavior: auto` so an exhausted table gives the wheel back. Driving that in a real browser turned up a defect no CSS reading would have found: `.api-panel` sets `overflow: hidden` for every panel on the tab, and a clipping ancestor swallows the wheel event at the end of the list. The handoff was dead. It is overridden for the model panel alone, with a guard. Usage examples left their disclosure. They are the answer to "how do I call this?", and a closed `<details>` answered it only for someone who already suspected the examples were in there. The catalog had one sentence for three situations. An empty catalog, a query matching nothing, and a load that failed now read differently, the failure carries its own retry beside the rows it repairs rather than a page banner that outlived them, and a refresh over cached rows keeps them and says it is working. `hasModelData` compared against `undefined` while the cache reader answers `null`, so a failed cold load rendered "no models available" about a catalog it never read. The locale test named eight keys out of seventy-four; it now compares the whole `api.` namespace across all six dictionaries, which catches missing and extra keys alike. `api.workspace.selectKeyHint` was in every dictionary and rendered nowhere. Tests: 463 pass across 92 files.
1 parent 6f52ef8 commit 56ffd7a

14 files changed

Lines changed: 471 additions & 240 deletions

gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export interface ApiKeysWorkspaceProps {
3939
filteredModels: ExternalModelRow[];
4040
modelsLoading: boolean;
4141
modelsLoadFailed: boolean;
42+
modelCount: number;
43+
hasModelData: boolean;
4244
modelQuery: string;
4345
copiedModelId: string | null;
4446
modelTests: ModelTests;
@@ -52,6 +54,7 @@ export interface ApiKeysWorkspaceProps {
5254
onModelQueryChange: (value: string) => void;
5355
onCopyModelId: (modelId: string) => void;
5456
onTestModel: (model: ExternalModelRow, protocol: GatewayInboundProtocol) => void;
57+
onRetryModels: () => void;
5558
sourceLabel: (model: ExternalModelRow) => string;
5659
protocolLabel: (protocol: GatewayInboundProtocol) => string;
5760
}
@@ -72,6 +75,8 @@ export default function ApiKeysWorkspace({
7275
filteredModels,
7376
modelsLoading,
7477
modelsLoadFailed,
78+
modelCount,
79+
hasModelData,
7580
modelQuery,
7681
copiedModelId,
7782
modelTests,
@@ -85,6 +90,7 @@ export default function ApiKeysWorkspace({
8590
onModelQueryChange,
8691
onCopyModelId,
8792
onTestModel,
93+
onRetryModels,
8894
sourceLabel,
8995
protocolLabel,
9096
}: ApiKeysWorkspaceProps) {
@@ -413,13 +419,16 @@ export default function ApiKeysWorkspace({
413419
filteredModels={filteredModels}
414420
modelsLoading={modelsLoading}
415421
modelsLoadFailed={modelsLoadFailed}
422+
modelCount={modelCount}
423+
hasModelData={hasModelData}
416424
modelQuery={modelQuery}
417425
copiedModelId={copiedModelId}
418426
modelTests={modelTests}
419427
claudeCodeEnabled={claudeCodeEnabled}
420428
onModelQueryChange={onModelQueryChange}
421429
onCopyModelId={onCopyModelId}
422430
onTestModel={onTestModel}
431+
onRetryModels={onRetryModels}
423432
canTestModels={canTestModels}
424433
sourceLabel={sourceLabel}
425434
protocolLabel={protocolLabel}

gui/src/i18n/de.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -823,6 +823,7 @@ export const de: Record<TKey, string> = {
823823
"api.modelsSubtitle": "Verwende diese exakten Modell-IDs mit /v1/models und dem gewählten eingehenden Protokoll.",
824824
"api.modelsLoading": "Modelle werden geladen…",
825825
"api.modelsEmpty": "Noch keine extern aufrufbaren Modelle verfügbar.",
826+
"api.modelsNoMatch": "Keine Modelle passen zu „{query}“.",
826827
"api.modelsLoadFailed": "Der externe Modellkatalog konnte nicht geladen werden.",
827828
"api.colModel": "Modell",
828829
"api.colSource": "Quelle",
@@ -861,7 +862,6 @@ export const de: Record<TKey, string> = {
861862
"api.workspace.deleteKey": "Schlüssel löschen",
862863
"api.workspace.deleteConfirm": "Diesen Schlüssel wirklich löschen? Das lässt sich nicht rückgängig machen.",
863864
"api.workspace.noKeysHint": "Noch keine API-Schlüssel. Erstelle einen, um zu starten.",
864-
"api.workspace.selectKeyHint": "Wähle einen Schlüssel aus der Liste, um Details zu sehen.",
865865
"api.workspace.usageExamples": "Nutzungsbeispiele",
866866
"api.copyUrlHint": "Klick um URL zu kopieren",
867867
"api.urlCopied": "URL kopiert",

gui/src/i18n/en.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1283,7 +1283,6 @@ export const en = {
12831283
"api.workspace.deleteKey": "Delete key",
12841284
"api.workspace.deleteConfirm": "Are you sure you want to delete this key? This cannot be undone.",
12851285
"api.workspace.noKeysHint": "No API keys yet. Generate one to get started.",
1286-
"api.workspace.selectKeyHint": "Select a key from the list to view its details.",
12871286
"api.workspace.usageExamples": "Usage examples",
12881287
"api.copyUrlHint": "Click to copy URL",
12891288
"api.urlCopied": "URL copied",
@@ -1300,6 +1299,7 @@ export const en = {
13001299
"api.modelsSearch": "Search models",
13011300
"api.modelsSubtitle": "Use these exact model IDs with /v1/models and your chosen inbound protocol.",
13021301
"api.modelsEmpty": "No externally callable models are available yet.",
1302+
"api.modelsNoMatch": "No models match “{query}”.",
13031303
"api.modelsLoadFailed": "Could not load the external model catalog.",
13041304
"api.colModel": "Model",
13051305
"api.colSource": "Source",

gui/src/i18n/ja.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1219,6 +1219,7 @@ export const ja: Record<TKey, string> = {
12191219
"api.modelsLoading": "モデルを読み込み中…",
12201220
"api.modelsLoadFailed": "外部モデルカタログを読み込めませんでした。",
12211221
"api.modelsEmpty": "外部から呼び出せるモデルはまだありません。",
1222+
"api.modelsNoMatch": "「{query}」に一致するモデルはありません。",
12221223
"api.colModel": "モデル",
12231224
"api.colSource": "ソース",
12241225
"api.colProtocols": "プロトコル",
@@ -1256,7 +1257,6 @@ export const ja: Record<TKey, string> = {
12561257
"api.workspace.deleteKey": "キーを削除",
12571258
"api.workspace.deleteConfirm": "このキーを削除しますか?この操作は元に戻せません。",
12581259
"api.workspace.noKeysHint": "API キーがまだありません。作成して始めましょう。",
1259-
"api.workspace.selectKeyHint": "一覧からキーを選択すると、詳細が表示されます。",
12601260
"api.workspace.usageExamples": "使用例",
12611261
"api.copyUrlHint": "クリックして URL をコピー",
12621262
"api.urlCopied": "URL をコピーしました",

gui/src/i18n/ko.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,7 @@ export const ko: Record<TKey, string> = {
850850
"api.modelsSubtitle": "이 정확한 모델 ID를 /v1/models와 선택한 인바운드 프로토콜과 함께 사용하세요.",
851851
"api.modelsLoading": "모델 불러오는 중…",
852852
"api.modelsEmpty": "아직 외부에서 호출 가능한 모델이 없습니다.",
853+
"api.modelsNoMatch": "“{query}”와 일치하는 모델이 없습니다.",
853854
"api.modelsLoadFailed": "외부 모델 카탈로그를 불러오지 못했습니다.",
854855
"api.colModel": "모델",
855856
"api.colSource": "출처",
@@ -888,7 +889,6 @@ export const ko: Record<TKey, string> = {
888889
"api.workspace.deleteKey": "키 삭제",
889890
"api.workspace.deleteConfirm": "이 키를 삭제하시겠습니까? 되돌릴 수 없습니다.",
890891
"api.workspace.noKeysHint": "아직 API 키가 없습니다. 키를 생성하여 시작하세요.",
891-
"api.workspace.selectKeyHint": "목록에서 키를 선택하여 세부 정보를 확인하세요.",
892892
"api.workspace.usageExamples": "사용 예제",
893893
"api.copyUrlHint": "클릭하여 URL 복사",
894894
"api.urlCopied": "URL 복사됨",

gui/src/i18n/ru.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1261,6 +1261,7 @@ export const ru: Record<TKey, string> = {
12611261
"api.modelsLoading": "Загрузка моделей…",
12621262
"api.modelsLoadFailed": "Не удалось загрузить каталог внешних моделей.",
12631263
"api.modelsEmpty": "Пока нет внешне доступных моделей.",
1264+
"api.modelsNoMatch": "Нет моделей, соответствующих «{query}».",
12641265
"api.colModel": "Модель",
12651266
"api.colSource": "Источник",
12661267
"api.colProtocols": "Протоколы",
@@ -1298,7 +1299,6 @@ export const ru: Record<TKey, string> = {
12981299
"api.workspace.deleteKey": "Удалить ключ",
12991300
"api.workspace.deleteConfirm": "Удалить этот ключ? Это действие нельзя отменить.",
13001301
"api.workspace.noKeysHint": "API-ключей пока нет. Создайте один, чтобы начать.",
1301-
"api.workspace.selectKeyHint": "Выберите ключ из списка, чтобы просмотреть сведения.",
13021302
"api.workspace.usageExamples": "Примеры использования",
13031303
"api.copyUrlHint": "Нажмите, чтобы скопировать URL",
13041304
"api.urlCopied": "URL скопирован",

gui/src/i18n/zh.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,7 @@ export const zh: Record<TKey, string> = {
843843
"api.modelsSubtitle": "请使用这些精确的模型 ID 搭配 /v1/models 和你选择的入站协议。",
844844
"api.modelsLoading": "正在加载模型…",
845845
"api.modelsEmpty": "还没有可供外部调用的模型。",
846+
"api.modelsNoMatch": "没有与“{query}”匹配的模型。",
846847
"api.modelsLoadFailed": "无法加载外部模型目录。",
847848
"api.colModel": "模型",
848849
"api.colSource": "来源",
@@ -881,7 +882,6 @@ export const zh: Record<TKey, string> = {
881882
"api.workspace.deleteKey": "删除密钥",
882883
"api.workspace.deleteConfirm": "确定要删除此密钥吗?此操作无法撤销。",
883884
"api.workspace.noKeysHint": "还没有 API 密钥。生成一个以开始使用。",
884-
"api.workspace.selectKeyHint": "从列表中选择一个密钥以查看其详情。",
885885
"api.workspace.usageExamples": "用法示例",
886886
"api.copyUrlHint": "点击复制 URL",
887887
"api.urlCopied": "已复制 URL",

gui/src/pages/ApiKeys.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
185185
// can never be presented as the server's answer.
186186
const authMatrix = keysData?.authMatrix ?? [];
187187
const refreshKeys = keysResource.refresh;
188+
const refreshModels = modelsResource.refresh;
188189

189190
const filteredModels = useMemo(() => {
190191
const query = modelQuery.trim().toLowerCase();
@@ -381,7 +382,9 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
381382

382383
{actionError && <Notice tone="err">{actionError}</Notice>}
383384
{keysState.showError && keysData && <Notice tone="err">{t("api.keysLoadFailed")}</Notice>}
384-
{modelsState.showError && keysData && <Notice tone="err">{t("api.modelsLoadFailed")}</Notice>}
385+
{/* The model failure is reported inside the models panel, beside the
386+
retry that repairs it. A page banner said it a second time and stayed
387+
up while the panel below it showed perfectly good cached rows. */}
385388

386389
{keysState.showSkeleton && !keysData ? (
387390
<DataSurfaceSkeleton label={t("api.activeKeysLoading")} rows={4} />
@@ -398,7 +401,10 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
398401
{keysState.refreshing && keysData && (
399402
<DataSurfaceStatus live={!keysState.showError}>{t("api.activeKeysLoading")}</DataSurfaceStatus>
400403
)}
401-
{modelsState.refreshing && modelsState.data && (
404+
{/* `modelsState.data` alone misses the session-cached case: after a
405+
failure the rows on screen come from `cachedModels`, and a retry
406+
then ran with no visible progress at all. */}
407+
{modelsState.refreshing && (modelsState.data !== undefined || cachedModels !== null) && (
402408
<DataSurfaceStatus live={!modelsState.showError && !(keysState.refreshing && keysData)}>
403409
{t("api.modelsLoading")}
404410
</DataSurfaceStatus>
@@ -419,6 +425,11 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
419425
filteredModels={filteredModels}
420426
modelsLoading={modelsState.showSkeleton && !modelsState.data && !cachedModels}
421427
modelsLoadFailed={modelsState.showError}
428+
modelCount={models.length}
429+
// `readSessionListCache` answers `null`, not `undefined`, when it has
430+
// nothing — comparing against `undefined` made a failed cold load look
431+
// like a successfully-read empty catalog.
432+
hasModelData={modelsState.data !== undefined || cachedModels !== null}
422433
modelQuery={modelQuery}
423434
copiedModelId={copiedModelId}
424435
modelTests={modelTests}
@@ -431,6 +442,7 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
431442
onModelQueryChange={setModelQuery}
432443
onCopyModelId={(modelId) => { void copyModelId(modelId); }}
433444
onTestModel={(model, protocol) => { void testModel(model, protocol); }}
445+
onRetryModels={() => { refreshModels({ forceLoading: true }); }}
434446
canTestModels={newKey !== null}
435447
sourceLabel={sourceLabel}
436448
protocolLabel={protocolLabel}

gui/src/pages/api-keys-panels.tsx

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -143,27 +143,37 @@ export function ApiKeysModelsPanel({
143143
filteredModels,
144144
modelsLoading,
145145
modelsLoadFailed,
146+
modelCount,
147+
hasModelData,
146148
modelQuery,
147149
copiedModelId,
148150
modelTests,
149151
claudeCodeEnabled,
150152
onModelQueryChange,
151153
onCopyModelId,
152154
onTestModel,
155+
onRetryModels,
153156
canTestModels,
154157
sourceLabel,
155158
protocolLabel,
156159
}: {
157160
filteredModels: ExternalModelRow[];
158161
modelsLoading: boolean;
159162
modelsLoadFailed: boolean;
163+
/** The whole catalog, before the search box narrowed it. "No models" and
164+
* "no models matching this query" are different sentences. */
165+
modelCount: number;
166+
/** True once a catalog has loaded at least once, from network or cache.
167+
* Distinguishes a server that really has no models from a failed cold load. */
168+
hasModelData: boolean;
160169
modelQuery: string;
161170
copiedModelId: string | null;
162171
modelTests: ModelTests;
163172
claudeCodeEnabled: boolean;
164173
onModelQueryChange: (value: string) => void;
165174
onCopyModelId: (modelId: string) => void;
166175
onTestModel: (model: ExternalModelRow, protocol: GatewayInboundProtocol) => void;
176+
onRetryModels: () => void;
167177
/** The GUI only ever holds the one-time key from a create, so an authenticated
168178
* test is available in that window and honestly disabled outside it. */
169179
canTestModels: boolean;
@@ -186,14 +196,31 @@ export function ApiKeysModelsPanel({
186196
placeholder={t("api.modelsSearch")}
187197
aria-label={t("api.modelsSearch")}
188198
/>
199+
{/* The retry sits beside the catalog it repairs, not in a page banner that
200+
outlives the panel. A failed refresh can coexist with last-good rows,
201+
so this is rendered alongside the table rather than instead of it. */}
202+
{modelsLoadFailed && (
203+
<div className="api-models-error">
204+
{/* The page-level notice this replaced was announced. Moving the
205+
message next to its retry must not also make it silent. */}
206+
<p className="muted small" role="alert">{t("api.modelsLoadFailed")}</p>
207+
<button type="button" className="btn btn-ghost btn-sm" onClick={onRetryModels}>
208+
{t("common.retry")}
209+
</button>
210+
</div>
211+
)}
189212
{modelsLoading ? (
190213
<DataSurfaceSkeleton label={t("api.modelsLoading")} rows={3} />
191-
) : modelsLoadFailed ? (
192-
// The page-level notice owns the persistent announcement and retry surface. Keeping this
193-
// panel mounted preserves the query field without adding a second live error region.
214+
) : !hasModelData ? (
215+
// Failed cold: the error above is the whole story. Adding "no models"
216+
// here would assert an empty catalog we never managed to read.
194217
null
195218
) : filteredModels.length === 0 ? (
196-
<p className="muted small" style={{ marginTop: "0.75rem" }}>{t("api.modelsEmpty")}</p>
219+
<p className="muted small api-models-empty">
220+
{modelCount === 0
221+
? t("api.modelsEmpty")
222+
: t("api.modelsNoMatch", { query: modelQuery.trim() })}
223+
</p>
197224
) : (
198225
<div className="api-models-scroll">
199226
<table className="tbl">
@@ -308,9 +335,12 @@ export function ApiKeysUsagePanel({
308335
}'`;
309336

310337
return (
311-
<details className="awi-usage-fold">
312-
<summary>{t("api.workspace.usageExamples")}</summary>
313-
<div className="awi-usage-fold-body">
338+
// Not a disclosure. These are the answer to "how do I call this?", and a
339+
// closed <details> answered it only for someone who already suspected the
340+
// examples were in there.
341+
<section className="panel api-panel awi-usage-panel">
342+
<h3 className="panel-title">{t("api.workspace.usageExamples")}</h3>
343+
<div className="awi-usage-panel-body">
314344
<div className="awi-usage-example">
315345
<h4 className="awi-usage-example-title">{t("api.usageChatTitle")}</h4>
316346
<CopyableExample text={chatExample} />
@@ -328,6 +358,6 @@ export function ApiKeysUsagePanel({
328358
</div>
329359
)}
330360
</div>
331-
</details>
361+
</section>
332362
);
333363
}

0 commit comments

Comments
 (0)