|
| 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 | +} |
0 commit comments