Skip to content

Commit fa2e0af

Browse files
committed
feat(gui): add the client config panel to the API tab
Phase 040 of devlog/_plan/260731_client_config_export. Renders what GET /api/client-config returns: a segmented OpenCode/Pi switch, the JSON itself, copy as the primary action, download as the secondary one, plus the destination path and the env line the config references. Download names the file from the server envelope and announces that nothing changed yet and the file must be merged. A downloaded config that reads as "applied" is how someone loses the other providers in their opencode.json. Rendering it in a real browser caught two defects a static read missed: a bare .awi-clientconfig-panel selector ties .api-panel's overflow on specificity and loses, leaving a clipping ancestor between the JSON and the page scroller; and a max-height on the JSON made a second capped scroll region on a tab whose invariant is that the model catalog is the only one. Both are fixed and the layout invariant test covers the second. Placement is the fallback 003 §6 allows, since the connect-bar rework has not landed yet.
1 parent 74a7d54 commit fa2e0af

11 files changed

Lines changed: 715 additions & 0 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ import {
2020
ApiKeysModelsPanel,
2121
ApiKeysUsagePanel,
2222
} from "../../pages/api-keys-panels";
23+
import ClientConfigPanel from "./ClientConfigPanel";
2324

2425
export interface ApiKeysWorkspaceProps {
2526
keys: ApiKeyEntry[];
27+
/** Management API origin the client-config panel fetches from. */
28+
apiBase: string;
2629
/** Dataset-level. Absent means nothing is attributable yet — a different
2730
* statement from a key whose counters read zero. */
2831
attributionSince?: string;
@@ -62,6 +65,7 @@ export interface ApiKeysWorkspaceProps {
6265

6366
export default function ApiKeysWorkspace({
6467
keys,
68+
apiBase,
6569
attributionSince,
6670
historyTruncated,
6771
authMatrix,
@@ -414,6 +418,10 @@ export default function ApiKeysWorkspace({
414418
onDelete={() => {}}
415419
/>
416420
<ApiKeysEndpointsPanel endpoints={endpoints} claudeCodeEnabled={claudeCodeEnabled} authMatrix={authMatrix} />
421+
{/* 003 §6 fallback placement: the connect-bar rework has not landed, so the
422+
panel sits in the left column directly below Endpoints — it answers the same
423+
"how do I point a client at this proxy" question. */}
424+
<ClientConfigPanel apiBase={apiBase} baseUrl={endpoints.baseUrl} hasKeys={keys.length > 0} />
417425
<ApiKeysUsagePanel endpoints={endpoints} claudeCodeEnabled={claudeCodeEnabled} />
418426
</div>
419427
<div className="awi-overview-right">
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/**
2+
* Client config export panel (devlog 260731_client_config_export/040).
3+
*
4+
* Renders what `GET /api/client-config` returns — it never builds a config locally, so the
5+
* bytes shown here are the bytes `ocx export` writes. Five states per 003 §4: loading,
6+
* ready, degraded (models without context limits), empty-key (informational, never
7+
* blocking), and error (retry, base URL stays visible, no partial JSON).
8+
*/
9+
import { useCallback, useEffect, useState } from "react";
10+
import { DataSurfaceSkeleton } from "../data-surface";
11+
import { useT } from "../../i18n/shared";
12+
import { apiErrorMessage } from "../../api-error";
13+
import { CopyableExample } from "../../pages/api-keys-copy";
14+
15+
/** Both clients the route serves. Kept in sync with EXPORT_CLIENT_IDS in src/clients/config-export.ts. */
16+
const CLIENTS = ["opencode", "pi"] as const;
17+
type ClientId = (typeof CLIENTS)[number];
18+
19+
/** The `/api/client-config` 200 envelope, read off the route rather than the design doc. */
20+
interface ClientConfigEnvelope {
21+
client: ClientId;
22+
/** Download filename. Server-owned: the panel must never name the file itself (003 §5). */
23+
filename: string;
24+
destination: string;
25+
apiKeyEnv: string;
26+
exportHint: string;
27+
modelCount: number;
28+
modelsWithoutLimits: number;
29+
config: unknown;
30+
}
31+
32+
const CLIENT_LABEL_KEYS = {
33+
opencode: "api.clientConfig.clientOpencode",
34+
pi: "api.clientConfig.clientPi",
35+
} as const;
36+
37+
export default function ClientConfigPanel({
38+
apiBase,
39+
baseUrl,
40+
hasKeys,
41+
}: {
42+
apiBase: string;
43+
/** Known independently of the catalog, so an error state can still show it (003 §4). */
44+
baseUrl: string;
45+
hasKeys: boolean;
46+
}) {
47+
const t = useT();
48+
const [client, setClient] = useState<ClientId>("opencode");
49+
/** Bumped by retry so the same client refetches without a fake client change. */
50+
const [attempt, setAttempt] = useState(0);
51+
/**
52+
* Every settled result and announcement carries the request it belongs to, and `loading`
53+
* is derived from that tag rather than reset in the effect body. Clearing state
54+
* synchronously on each client switch was a cascading render, and it also left a window
55+
* where a stale payload could be copied under the newly selected client.
56+
*/
57+
const requestKey = `${client}:${attempt}`;
58+
const [result, setResult] = useState<
59+
{ key: string; data: ClientConfigEnvelope | null; error: string | null } | null
60+
>(null);
61+
const [announced, setAnnounced] = useState<{ key: string; text: string } | null>(null);
62+
63+
useEffect(() => {
64+
const controller = new AbortController();
65+
void (async () => {
66+
try {
67+
const res = await fetch(`${apiBase}/api/client-config?client=${encodeURIComponent(client)}`, {
68+
signal: controller.signal,
69+
});
70+
if (!res.ok) throw new Error(await apiErrorMessage(res, t("api.clientConfig.loadFailed")));
71+
const envelope = await res.json() as ClientConfigEnvelope;
72+
if (controller.signal.aborted) return;
73+
setResult({ key: requestKey, data: envelope, error: null });
74+
} catch (cause) {
75+
if (controller.signal.aborted) return;
76+
const message = cause instanceof Error && cause.message ? cause.message : t("api.clientConfig.loadFailed");
77+
setResult({ key: requestKey, data: null, error: message });
78+
}
79+
})();
80+
return () => controller.abort();
81+
}, [apiBase, client, requestKey, t]);
82+
83+
const settled = result !== null && result.key === requestKey ? result : null;
84+
const loading = settled === null;
85+
const data = settled?.data ?? null;
86+
const error = settled?.error ?? null;
87+
const announcement = announced !== null && announced.key === requestKey ? announced.text : "";
88+
89+
// eslint-disable-next-line local-i18n/no-hardcoded-ui-strings -- file content newline, not UI text
90+
const json = data ? `${JSON.stringify(data.config, null, 2)}\n` : "";
91+
92+
const copyJson = useCallback(async () => {
93+
if (!data) return;
94+
try {
95+
await navigator.clipboard.writeText(json);
96+
setAnnounced({ key: requestKey, text: t("api.clientConfig.copiedAnnounce") });
97+
} catch {
98+
setAnnounced({ key: requestKey, text: t("api.clientConfig.copyFailed") });
99+
}
100+
}, [data, json, requestKey, t]);
101+
102+
const downloadJson = useCallback(() => {
103+
if (!data) return;
104+
// Mechanics per ClaudeDesktop.exportProfile: the anchor is an implementation detail of a
105+
// real <button>, and the filename comes from the envelope so it matches the destination
106+
// file's own name.
107+
const url = URL.createObjectURL(new Blob([json], { type: "application/json" }));
108+
const anchor = document.createElement("a");
109+
anchor.href = url;
110+
anchor.download = data.filename;
111+
anchor.click();
112+
URL.revokeObjectURL(url);
113+
// "Downloaded", never "applied": a file in ~/Downloads changed nothing yet (003 §5).
114+
setAnnounced({
115+
key: requestKey,
116+
text: t("api.clientConfig.downloadedAnnounce", {
117+
filename: data.filename,
118+
destination: data.destination,
119+
}),
120+
});
121+
}, [data, json, requestKey, t]);
122+
123+
return (
124+
<section className="panel api-panel awi-clientconfig-panel">
125+
<div className="api-panel-head awi-clientconfig-head">
126+
<h3 className="panel-title">{t("api.clientConfig.title")}</h3>
127+
<span className="awi-clientconfig-actions">
128+
<button
129+
type="button"
130+
className="btn btn-primary btn-sm"
131+
onClick={() => { void copyJson(); }}
132+
disabled={!data}
133+
>
134+
{t("api.clientConfig.copy")}
135+
</button>
136+
<button
137+
type="button"
138+
className="btn btn-sm"
139+
onClick={downloadJson}
140+
disabled={!data}
141+
>
142+
{t("api.clientConfig.download")}
143+
</button>
144+
</span>
145+
</div>
146+
147+
<div className="segmented awi-clientconfig-segmented" role="radiogroup" aria-label={t("api.clientConfig.clientLabel")}>
148+
{CLIENTS.map(option => (
149+
<button
150+
key={option}
151+
type="button"
152+
role="radio"
153+
aria-checked={client === option}
154+
className={`btn btn-sm${client === option ? " btn-primary" : " btn-ghost"}`}
155+
onClick={() => setClient(option)}
156+
>
157+
{t(CLIENT_LABEL_KEYS[option])}
158+
</button>
159+
))}
160+
</div>
161+
162+
{loading ? (
163+
// Owns the only live region for this transition; the announcement region below is not
164+
// rendered while cold (data-surface.tsx header rule).
165+
<DataSurfaceSkeleton label={t("api.clientConfig.loading")} rows={4} className="awi-clientconfig-skeleton" />
166+
) : error !== null || !data ? (
167+
<div className="awi-clientconfig-error">
168+
{/* No partial JSON here: without the model list the config cannot be produced honestly. */}
169+
<p className="muted small" role="alert">{error ?? t("api.clientConfig.loadFailed")}</p>
170+
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setAttempt(n => n + 1)}>
171+
{t("common.retry")}
172+
</button>
173+
</div>
174+
) : (
175+
<>
176+
<pre
177+
className="api-code api-example-pre awi-clientconfig-json"
178+
tabIndex={0}
179+
role="group"
180+
aria-label={t("api.clientConfig.jsonLabel", { client: t(CLIENT_LABEL_KEYS[client]) })}
181+
>{json}</pre>
182+
<p className="muted small awi-clientconfig-count">
183+
{t("api.clientConfig.modelCount", { count: data.modelCount })}
184+
</p>
185+
{data.modelsWithoutLimits > 0 && (
186+
<p className="muted small awi-clientconfig-degraded">
187+
{t("api.clientConfig.missingLimits", { count: data.modelsWithoutLimits, total: data.modelCount })}
188+
</p>
189+
)}
190+
{!hasKeys && (
191+
// Informational, never blocking: an agent may legitimately want the shape first,
192+
// so both actions stay enabled (003 §3).
193+
<p className="muted small awi-clientconfig-nokey">
194+
{t("api.clientConfig.noKeyYet", { env: data.apiKeyEnv })}
195+
</p>
196+
)}
197+
<div className="awi-clientconfig-line">
198+
<span className="muted text-label">{t("api.clientConfig.destination")}</span>
199+
<CopyableExample text={data.destination} />
200+
</div>
201+
<div className="awi-clientconfig-line">
202+
<span className="muted text-label">{t("api.clientConfig.envHint")}</span>
203+
<CopyableExample text={data.exportHint} />
204+
</div>
205+
<p className="muted small awi-clientconfig-merge">{t("api.clientConfig.mergeWarning")}</p>
206+
</>
207+
)}
208+
209+
<div className="awi-clientconfig-line">
210+
{/* Known without the catalog, so it survives the error state. */}
211+
<span className="muted text-label">{t("api.baseUrl")}</span>
212+
<CopyableExample text={baseUrl} />
213+
</div>
214+
215+
<details className="awi-clientconfig-where">
216+
<summary className="muted text-label">{t("api.clientConfig.whereDisclosure")}</summary>
217+
<p className="muted small">{t("api.clientConfig.whereBody")}</p>
218+
</details>
219+
220+
{/* Copy/download announcements only once the surface is ready, so the skeleton and this
221+
region never speak for the same transition. */}
222+
{!loading && data !== null && (
223+
<div className="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>
224+
)}
225+
</section>
226+
);
227+
}

gui/src/i18n/de.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,26 @@ export const de: Record<TKey, string> = {
887887
"api.confirm": "Bestätigen",
888888
"api.deleteAria": "API-Schlüssel löschen",
889889
"api.usageSampleInput": "Hallo, Welt!",
890+
"api.clientConfig.title": "Client-Konfiguration",
891+
"api.clientConfig.clientLabel": "Client",
892+
"api.clientConfig.clientOpencode": "OpenCode",
893+
"api.clientConfig.clientPi": "Pi",
894+
"api.clientConfig.copy": "JSON kopieren",
895+
"api.clientConfig.download": "Herunterladen",
896+
"api.clientConfig.loading": "Client-Konfiguration wird erstellt…",
897+
"api.clientConfig.jsonLabel": "{client}-Konfigurations-JSON",
898+
"api.clientConfig.destination": "Zieldatei",
899+
"api.clientConfig.envHint": "Schlüssel vor dem Start setzen",
900+
"api.clientConfig.mergeWarning": "Führe dies in die Zieldatei ein. Ein Ersetzen würde deine anderen Provider und MCP-Einstellungen entfernen.",
901+
"api.clientConfig.modelCount": "{count} Modell(e) exportiert",
902+
"api.clientConfig.missingLimits": "{count} von {total} Modell(en) haben kein Kontextlimit; der Client verwendet dafür seine eigenen Vorgaben.",
903+
"api.clientConfig.noKeyYet": "Für {env} existiert noch kein Schlüssel. Erzeuge oben einen Schlüssel, bevor du diese Konfiguration außerhalb von Loopback nutzt.",
904+
"api.clientConfig.loadFailed": "Die Modellliste konnte nicht gelesen werden, daher wurde keine Client-Konfiguration erzeugt.",
905+
"api.clientConfig.copiedAnnounce": "Client-Konfigurations-JSON in die Zwischenablage kopiert.",
906+
"api.clientConfig.copyFailed": "Client-Konfigurations-JSON konnte nicht kopiert werden.",
907+
"api.clientConfig.downloadedAnnounce": "{filename} heruntergeladen. Es hat sich noch nichts geändert — führe die Datei selbst in {destination} ein.",
908+
"api.clientConfig.whereDisclosure": "Wohin diese Datei gehört",
909+
"api.clientConfig.whereBody": "Der Pfad oben ist der globale Speicherort. Eine projektlokale Konfigurationsdatei im Arbeitsverzeichnis hat Vorrang, und der Schlüssel wird aus der in der Konfiguration genannten Umgebungsvariable gelesen — nie aus dieser Datei.",
890910
"api.keysLoadFailed": "API-Schlüssel konnten nicht geladen werden.",
891911
"api.createFailed": "API-Schlüssel konnte nicht erstellt werden.",
892912
"api.deleteFailed": "API-Schlüssel konnte nicht gelöscht werden.",

gui/src/i18n/en.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,6 +1335,26 @@ export const en = {
13351335
"api.usageResponsesTitle": "Responses example",
13361336
"api.usageMessagesTitle": "Messages example",
13371337
"api.usageSampleInput": "Hello, world!",
1338+
"api.clientConfig.title": "Client config",
1339+
"api.clientConfig.clientLabel": "Client",
1340+
"api.clientConfig.clientOpencode": "OpenCode",
1341+
"api.clientConfig.clientPi": "Pi",
1342+
"api.clientConfig.copy": "Copy JSON",
1343+
"api.clientConfig.download": "Download",
1344+
"api.clientConfig.loading": "Building client config…",
1345+
"api.clientConfig.jsonLabel": "{client} config JSON",
1346+
"api.clientConfig.destination": "Destination file",
1347+
"api.clientConfig.envHint": "Set the key before launching",
1348+
"api.clientConfig.mergeWarning": "Merge this into the destination file. Replacing it would drop your other providers and MCP settings.",
1349+
"api.clientConfig.modelCount": "{count} model(s) exported",
1350+
"api.clientConfig.missingLimits": "{count} of {total} model(s) ship without a context limit; the client applies its own defaults.",
1351+
"api.clientConfig.noKeyYet": "{env} has no key behind it yet. Generate a key above before using this config off loopback.",
1352+
"api.clientConfig.loadFailed": "Could not read the model list, so no client config was produced.",
1353+
"api.clientConfig.copiedAnnounce": "Client config JSON copied to the clipboard.",
1354+
"api.clientConfig.copyFailed": "Could not copy the client config JSON.",
1355+
"api.clientConfig.downloadedAnnounce": "Downloaded {filename}. Nothing changed yet — merge it into {destination} yourself.",
1356+
"api.clientConfig.whereDisclosure": "Where this file goes",
1357+
"api.clientConfig.whereBody": "The destination above is the global path. A project-local config file in the working directory takes precedence over it, and the client reads the key from the environment variable named in the config — never from this file.",
13381358
"api.keysLoadFailed": "Could not load API keys.",
13391359
"api.createFailed": "Could not create API key.",
13401360
"api.deleteFailed": "Could not delete API key.",

gui/src/i18n/ja.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,6 +1283,26 @@ export const ja: Record<TKey, string> = {
12831283
"api.confirm": "確認",
12841284
"api.deleteAria": "API キーを削除",
12851285
"api.usageSampleInput": "こんにちは、世界!",
1286+
"api.clientConfig.title": "クライアント設定",
1287+
"api.clientConfig.clientLabel": "クライアント",
1288+
"api.clientConfig.clientOpencode": "OpenCode",
1289+
"api.clientConfig.clientPi": "Pi",
1290+
"api.clientConfig.copy": "JSON をコピー",
1291+
"api.clientConfig.download": "ダウンロード",
1292+
"api.clientConfig.loading": "クライアント設定を生成中…",
1293+
"api.clientConfig.jsonLabel": "{client} 設定 JSON",
1294+
"api.clientConfig.destination": "配置先ファイル",
1295+
"api.clientConfig.envHint": "起動前にキーを設定",
1296+
"api.clientConfig.mergeWarning": "配置先ファイルにマージしてください。置き換えると既存のプロバイダーや MCP 設定が失われます。",
1297+
"api.clientConfig.modelCount": "{count} 件のモデルを書き出しました",
1298+
"api.clientConfig.missingLimits": "{total} 件中 {count} 件のモデルにコンテキスト上限がないため、クライアント側の既定値が使われます。",
1299+
"api.clientConfig.noKeyYet": "{env} に対応するキーがまだありません。ループバック外で使う前に上でキーを発行してください。",
1300+
"api.clientConfig.loadFailed": "モデル一覧を読み取れなかったため、クライアント設定を生成できませんでした。",
1301+
"api.clientConfig.copiedAnnounce": "クライアント設定 JSON をクリップボードにコピーしました。",
1302+
"api.clientConfig.copyFailed": "クライアント設定 JSON をコピーできませんでした。",
1303+
"api.clientConfig.downloadedAnnounce": "{filename} をダウンロードしました。まだ何も変わっていません。{destination} に自分でマージしてください。",
1304+
"api.clientConfig.whereDisclosure": "このファイルの置き場所",
1305+
"api.clientConfig.whereBody": "上のパスはグローバル設定の場所です。作業ディレクトリのプロジェクト設定ファイルが優先され、キーは設定に書かれた環境変数から読み込まれ、このファイルには保存されません。",
12861306
"api.keysLoadFailed": "APIキーを読み込めませんでした。",
12871307
"api.createFailed": "APIキーを作成できませんでした。",
12881308
"api.deleteFailed": "APIキーを削除できませんでした。",

gui/src/i18n/ko.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,26 @@ export const ko: Record<TKey, string> = {
914914
"api.confirm": "확인",
915915
"api.deleteAria": "API 키 삭제",
916916
"api.usageSampleInput": "안녕하세요, 세계!",
917+
"api.clientConfig.title": "클라이언트 설정",
918+
"api.clientConfig.clientLabel": "클라이언트",
919+
"api.clientConfig.clientOpencode": "OpenCode",
920+
"api.clientConfig.clientPi": "Pi",
921+
"api.clientConfig.copy": "JSON 복사",
922+
"api.clientConfig.download": "다운로드",
923+
"api.clientConfig.loading": "클라이언트 설정 생성 중…",
924+
"api.clientConfig.jsonLabel": "{client} 설정 JSON",
925+
"api.clientConfig.destination": "대상 파일",
926+
"api.clientConfig.envHint": "실행 전 키 설정",
927+
"api.clientConfig.mergeWarning": "대상 파일에 병합하세요. 덮어쓰면 기존 프로바이더와 MCP 설정이 사라집니다.",
928+
"api.clientConfig.modelCount": "모델 {count}개 내보냄",
929+
"api.clientConfig.missingLimits": "{total}개 중 {count}개 모델에 컨텍스트 한도가 없어 클라이언트 기본값이 적용됩니다.",
930+
"api.clientConfig.noKeyYet": "{env}에 연결된 키가 아직 없습니다. 루프백 밖에서 쓰려면 위에서 키를 발급하세요.",
931+
"api.clientConfig.loadFailed": "모델 목록을 읽지 못해 클라이언트 설정을 만들지 못했습니다.",
932+
"api.clientConfig.copiedAnnounce": "클라이언트 설정 JSON을 클립보드에 복사했습니다.",
933+
"api.clientConfig.copyFailed": "클라이언트 설정 JSON을 복사하지 못했습니다.",
934+
"api.clientConfig.downloadedAnnounce": "{filename} 파일을 다운로드했습니다. 아직 아무것도 바뀌지 않았으니 {destination}에 직접 병합하세요.",
935+
"api.clientConfig.whereDisclosure": "이 파일이 들어갈 위치",
936+
"api.clientConfig.whereBody": "위 경로는 전역 설정 경로입니다. 작업 디렉터리의 프로젝트 설정 파일이 우선하며, 키는 설정에 적힌 환경 변수에서 읽고 이 파일에는 저장되지 않습니다.",
917937
"api.keysLoadFailed": "API 키를 불러오지 못했습니다.",
918938
"api.createFailed": "API 키를 만들지 못했습니다.",
919939
"api.deleteFailed": "API 키를 삭제하지 못했습니다.",

0 commit comments

Comments
 (0)