| 1 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 2 | import { ChevronDown, ChevronRight, Clipboard, Loader2, RefreshCw } from "lucide-react"; |
| 3 | import { app } from "../lib/bridge"; |
| 4 | import { asArray } from "../lib/array"; |
| 5 | import { useI18n, useT, type Locale } from "../lib/i18n"; |
| 6 | import type { CapabilityDiagnosticsReport, CapabilityIssue, CredentialDiagnosticReport, RuntimeDoctorReport, SettingsTab } from "../lib/types"; |
| 7 | import { FrontendDiagnosticsControl } from "./FrontendDiagnosticsControl"; |
| 8 | |
| 9 | const FRONTEND_COPY: Record<Locale, { title: string; hint: string }> = { |
| 10 | en: { |
| 11 | title: "Frontend interaction recording", |
| 12 | hint: "When scrolling jumps, sessions switch, or the UI flickers, turn this on, reproduce the issue, then turn it off and choose where to export. Only timing, events, and geometry are recorded; conversation content, input values, paths, and secrets are excluded.", |
| 13 | }, |
| 14 | zh: { |
| 15 | title: "前端交互记录", |
| 16 | hint: "遇到滚动闪回、会话切换或界面抖动时,打开记录开关,复现问题后关闭并选择路径导出。仅记录时间、事件和几何信息,不记录对话内容、输入值、路径或密钥。", |
| 17 | }, |
| 18 | "zh-TW": { |
| 19 | title: "前端互動記錄", |
| 20 | hint: "遇到捲動閃回、工作階段切換或介面抖動時,開啟記錄開關,重現問題後關閉並選擇路徑匯出。僅記錄時間、事件與幾何資訊,不記錄對話內容、輸入值、路徑或密鑰。", |
| 21 | }, |
| 22 | }; |
| 23 | |
| 24 | const CREDENTIAL_COPY: Record<Locale, { title: string; probe: string; preview: string; repair: string }> = { |
| 25 | en: { title: "Credential diagnostics", probe: "Check write access", preview: "Preview repair", repair: "Repair credential access" }, |
| 26 | zh: { title: "凭据诊断", probe: "检查写入能力", preview: "预览修复", repair: "修复凭据访问" }, |
| 27 | "zh-TW": { title: "憑據診斷", probe: "檢查寫入能力", preview: "預覽修復", repair: "修復憑據存取" }, |
| 28 | }; |
| 29 | |
| 30 | export function DiagnosticsSettingsPage({ |
| 31 | onNavigate, |
| 32 | }: { |
| 33 | onNavigate?: (tab: SettingsTab) => void; |
| 34 | }) { |
| 35 | const t = useT(); |
| 36 | const { locale } = useI18n(); |
| 37 | const frontendCopy = FRONTEND_COPY[locale]; |
| 38 | const credentialCopy = CREDENTIAL_COPY[locale]; |
| 39 | const [report, setReport] = useState<CapabilityDiagnosticsReport | null>(null); |
| 40 | const [runtimeDoctor, setRuntimeDoctor] = useState<RuntimeDoctorReport | null>(null); |
| 41 | const [credentialReport, setCredentialReport] = useState<CredentialDiagnosticReport | null>(null); |
| 42 | const [credentialBusy, setCredentialBusy] = useState(false); |
| 43 | const [loading, setLoading] = useState(true); |
| 44 | const [error, setError] = useState<string | null>(null); |
| 45 | const [includeRuntime, setIncludeRuntime] = useState(false); |
| 46 | const [copied, setCopied] = useState(false); |
| 47 | const [open, setOpen] = useState<Record<string, boolean>>({ |
| 48 | issues: true, |
| 49 | runtime: true, |
| 50 | instructions: false, |
| 51 | skills: false, |
| 52 | commands: false, |
| 53 | hooks: false, |
| 54 | plugins: false, |
| 55 | mcp: false, |
| 56 | }); |
| 57 | |
| 58 | const loadSeq = useRef(0); |
| 59 | |
| 60 | const load = useCallback(async (runtime: boolean) => { |
| 61 | const seq = ++loadSeq.current; |
| 62 | setLoading(true); |
| 63 | setError(null); |
| 64 | try { |
| 65 | const next = normalizeDiagnosticsReport(await app.CapabilityDiagnostics(runtime)); |
| 66 | let doctor: RuntimeDoctorReport | null = null; |
| 67 | try { |
| 68 | doctor = await app.RuntimeDoctor(); |
| 69 | } catch { |
| 70 | doctor = null; |
| 71 | } |
| 72 | // Last-request-wins: ignore stale responses after rapid refresh/toggle. |
| 73 | if (seq !== loadSeq.current) return; |
| 74 | setReport(next); |
| 75 | setRuntimeDoctor(doctor); |
| 76 | try { |
| 77 | setCredentialReport(await app.CredentialDiagnostics(false)); |
| 78 | } catch { |
| 79 | setCredentialReport(null); |
| 80 | } |
| 81 | } catch (err) { |
| 82 | if (seq !== loadSeq.current) return; |
| 83 | setError(err instanceof Error ? err.message : String(err)); |
| 84 | setReport(null); |
| 85 | setRuntimeDoctor(null); |
| 86 | } finally { |
| 87 | if (seq === loadSeq.current) { |
| 88 | setLoading(false); |
| 89 | } |
| 90 | } |
| 91 | }, []); |
| 92 | |
| 93 | useEffect(() => { |
| 94 | void load(includeRuntime); |
| 95 | }, [includeRuntime, load]); |
| 96 | |
| 97 | const issuesBySeverity = useMemo(() => { |
| 98 | const groups: Record<string, CapabilityIssue[]> = { error: [], warning: [], info: [] }; |
| 99 | for (const issue of report?.issues ?? []) { |
| 100 | const key = issue.severity === "error" || issue.severity === "warning" ? issue.severity : "info"; |
| 101 | groups[key].push(issue); |
| 102 | } |
| 103 | return groups; |
| 104 | }, [report]); |
| 105 | |
| 106 | const copyJSON = async () => { |
| 107 | if (!report) return; |
| 108 | try { |
| 109 | await navigator.clipboard.writeText(JSON.stringify(report, null, 2)); |
| 110 | setCopied(true); |
| 111 | window.setTimeout(() => setCopied(false), 1500); |
| 112 | } catch { |
| 113 | setError(t("diag.copyFailed")); |
| 114 | } |
| 115 | }; |
| 116 | |
| 117 | const toggle = (key: string) => setOpen((prev) => ({ ...prev, [key]: !prev[key] })); |
| 118 | |
| 119 | const runCredentialAction = async (kind: "probe" | "preview" | "repair") => { |
| 120 | setCredentialBusy(true); |
| 121 | setError(null); |
| 122 | try { |
| 123 | setCredentialReport(kind === "probe" ? await app.CredentialDiagnostics(true) : await app.RepairCredentials(kind === "preview")); |
| 124 | } catch (err) { |
| 125 | setError(err instanceof Error ? err.message : String(err)); |
| 126 | } finally { |
| 127 | setCredentialBusy(false); |
| 128 | } |
| 129 | }; |
| 130 | |
| 131 | const goSettings = (tab?: string) => { |
| 132 | if (!tab || !onNavigate) return; |
| 133 | const allowed: SettingsTab[] = ["mcp", "skills", "plugins", "hooks"]; |
| 134 | if (allowed.includes(tab as SettingsTab)) { |
| 135 | onNavigate(tab as SettingsTab); |
| 136 | } |
| 137 | }; |
| 138 | |
| 139 | return ( |
| 140 | <div className="diag-page"> |
| 141 | <div className="diag-page__toolbar settings-toolbar"> |
| 142 | <label className="diag-page__runtime"> |
| 143 | <input |
| 144 | type="checkbox" |
| 145 | checked={includeRuntime} |
| 146 | onChange={(e) => setIncludeRuntime(e.target.checked)} |
| 147 | /> |
| 148 | <span>{t("diag.includeRuntime")}</span> |
| 149 | </label> |
| 150 | <div className="diag-page__actions"> |
| 151 | <button type="button" className="btn btn--ghost" onClick={() => void load(includeRuntime)} disabled={loading}> |
| 152 | {loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />} |
| 153 | <span>{t("diag.refresh")}</span> |
| 154 | </button> |
| 155 | <button type="button" className="btn btn--ghost" onClick={() => void copyJSON()} disabled={!report}> |
| 156 | <Clipboard size={14} /> |
| 157 | <span>{copied ? t("diag.copied") : t("diag.copyJson")}</span> |
| 158 | </button> |
| 159 | </div> |
| 160 | </div> |
| 161 | |
| 162 | <p className="diag-page__hint">{t("diag.hint")}</p> |
| 163 | |
| 164 | <section className="diag-section" data-testid="credential-diagnostics-settings"> |
| 165 | <div className="diag-section__header"> |
| 166 | <span>{credentialCopy.title}</span> |
| 167 | </div> |
| 168 | <div className="diag-section__body"> |
| 169 | <p className="diag-path">{credentialReport?.credentialPath}</p> |
| 170 | {(credentialReport?.checks ?? []).map((check) => ( |
| 171 | <div key={check.id} className={`diag-issue diag-issue--${check.status === "failed" ? "error" : "info"}`}> |
| 172 | <header><code>{check.id}</code><span>{check.status}</span></header> |
| 173 | {check.message && <p className="diag-issue__msg">{check.message}</p>} |
| 174 | </div> |
| 175 | ))} |
| 176 | {(credentialReport?.actions ?? []).map((action) => <p key={action} className="diag-issue__fix">{action}</p>)} |
| 177 | <div className="diag-page__actions"> |
| 178 | <button type="button" className="btn btn--ghost" disabled={credentialBusy} onClick={() => void runCredentialAction("probe")}>{credentialCopy.probe}</button> |
| 179 | <button type="button" className="btn btn--ghost" disabled={credentialBusy} onClick={() => void runCredentialAction("preview")}>{credentialCopy.preview}</button> |
| 180 | <button type="button" className="btn btn--secondary" disabled={credentialBusy} onClick={() => void runCredentialAction("repair")}>{credentialCopy.repair}</button> |
| 181 | </div> |
| 182 | </div> |
| 183 | </section> |
| 184 | |
| 185 | <section className="diag-section diag-section--frontend" data-testid="frontend-diagnostics-settings"> |
| 186 | <div className="diag-section__body diag-section__body--frontend"> |
| 187 | <div className="diag-frontend-recording__copy"> |
| 188 | <strong>{frontendCopy.title}</strong> |
| 189 | <span>{frontendCopy.hint}</span> |
| 190 | </div> |
| 191 | <FrontendDiagnosticsControl embedded /> |
| 192 | </div> |
| 193 | </section> |
| 194 | |
| 195 | {loading && !report && <div className="empty">{t("settings.loading")}</div>} |
| 196 | {error && <div className="settings-error" role="alert">{error}</div>} |
| 197 | |
| 198 | {report && ( |
| 199 | <> |
| 200 | <div className="diag-summary"> |
| 201 | <div className="diag-summary__item diag-summary__item--error"> |
| 202 | <strong>{report.summary.errors}</strong> |
| 203 | <span>{t("diag.errors")}</span> |
| 204 | </div> |
| 205 | <div className="diag-summary__item diag-summary__item--warning"> |
| 206 | <strong>{report.summary.warnings}</strong> |
| 207 | <span>{t("diag.warnings")}</span> |
| 208 | </div> |
| 209 | <div className="diag-summary__item diag-summary__item--info"> |
| 210 | <strong>{report.summary.infos}</strong> |
| 211 | <span>{t("diag.infos")}</span> |
| 212 | </div> |
| 213 | <div className="diag-summary__meta"> |
| 214 | <span className="diag-path">{report.root}</span> |
| 215 | <span> |
| 216 | {t("diag.counts", { |
| 217 | skills: report.summary.skills, |
| 218 | commands: report.summary.commands, |
| 219 | hooks: report.summary.hooks, |
| 220 | plugins: report.summary.plugins, |
| 221 | mcp: report.summary.mcp_servers, |
| 222 | })} |
| 223 | </span> |
| 224 | </div> |
| 225 | </div> |
| 226 | |
| 227 | {runtimeDoctor && ( |
| 228 | <section className="diag-section"> |
| 229 | <button type="button" className="diag-section__header" onClick={() => toggle("runtime")}> |
| 230 | {open.runtime ? <ChevronDown size={16} /> : <ChevronRight size={16} />} |
| 231 | <span>Extension runtime (v2)</span> |
| 232 | </button> |
| 233 | {open.runtime && ( |
| 234 | <div className="diag-section__body"> |
| 235 | <div className="diag-summary"> |
| 236 | <div className="diag-summary__item"> |
| 237 | <strong>{runtimeDoctor.publishedGeneration}</strong> |
| 238 | <span>generation</span> |
| 239 | </div> |
| 240 | <div className="diag-summary__item"> |
| 241 | <strong>{runtimeDoctor.allowResume ? "yes" : "no"}</strong> |
| 242 | <span>allow resume</span> |
| 243 | </div> |
| 244 | <div className="diag-summary__item"> |
| 245 | <strong>{runtimeDoctor.cleanRollback ? "yes" : "no"}</strong> |
| 246 | <span>clean rollback</span> |
| 247 | </div> |
| 248 | <div className="diag-summary__meta"> |
| 249 | <span> |
| 250 | no-op={runtimeDoctor.noOpRebuilds} subgraph={runtimeDoctor.subgraphRebuilds} full={runtimeDoctor.fullRebuilds}{" "} |
| 251 | staleDrops={runtimeDoctor.staleDrops} admitReject={runtimeDoctor.admissionRejected} ownerFallbacks={runtimeDoctor.runtimeOwnerFallbacks} |
| 252 | </span> |
| 253 | </div> |
| 254 | </div> |
| 255 | <pre className="diag-path" style={{ whiteSpace: "pre-wrap", marginTop: 8 }}> |
| 256 | {runtimeDoctor.text} |
| 257 | </pre> |
| 258 | {runtimeDoctor.skillWatch && ( |
| 259 | <div className="diag-summary" data-testid="skill-watch-diagnostics"> |
| 260 | <div className="diag-summary__item"><strong>{runtimeDoctor.skillWatch.physicalWatches}</strong><span>physical watches</span></div> |
| 261 | <div className="diag-summary__item"><strong>{runtimeDoctor.skillWatch.logicalSubscriptions}</strong><span>subscriptions</span></div> |
| 262 | <div className="diag-summary__item"><strong>{runtimeDoctor.skillWatch.scans}</strong><span>fallback scans</span></div> |
| 263 | <div className="diag-summary__item"><strong>{runtimeDoctor.skillWatch.degradedRoots}</strong><span>degraded roots</span></div> |
| 264 | <div className="diag-summary__meta"> |
| 265 | <span>entries={runtimeDoctor.skillWatch.scannedEntries} events={runtimeDoctor.skillWatch.eventsReceived} notifications={runtimeDoctor.skillWatch.notifications} helperRestarts={runtimeDoctor.skillWatch.helperRestarts}</span> |
| 266 | </div> |
| 267 | </div> |
| 268 | )} |
| 269 | </div> |
| 270 | )} |
| 271 | </section> |
| 272 | )} |
| 273 | |
| 274 | <section className="diag-section"> |
| 275 | <button type="button" className="diag-section__header" onClick={() => toggle("issues")}> |
| 276 | {open.issues ? <ChevronDown size={16} /> : <ChevronRight size={16} />} |
| 277 | <span>{t("diag.issues")} ({report.issues.length})</span> |
| 278 | </button> |
| 279 | {open.issues && ( |
| 280 | <div className="diag-section__body"> |
| 281 | {report.issues.length === 0 && <div className="empty">{t("diag.noIssues")}</div>} |
| 282 | {(["error", "warning", "info"] as const).map((sev) => |
| 283 | issuesBySeverity[sev].length === 0 ? null : ( |
| 284 | <div key={sev} className={`diag-issue-group diag-issue-group--${sev}`}> |
| 285 | <h4>{t(`diag.severity.${sev}` as "diag.severity.error")}</h4> |
| 286 | {issuesBySeverity[sev].map((issue, idx) => ( |
| 287 | <article key={`${issue.code}-${issue.name ?? ""}-${idx}`} className="diag-issue"> |
| 288 | <header> |
| 289 | <code>{issue.code}</code> |
| 290 | {issue.name ? <span className="diag-issue__name">{issue.name}</span> : null} |
| 291 | </header> |
| 292 | <p className="diag-issue__msg">{issue.message}</p> |
| 293 | {issue.source ? <p className="diag-path">{issue.source}</p> : null} |
| 294 | {issue.remediation ? <p className="diag-issue__fix">{issue.remediation}</p> : null} |
| 295 | {issue.settings_tab && onNavigate ? ( |
| 296 | <button type="button" className="btn btn--ghost btn--small" onClick={() => goSettings(issue.settings_tab)}> |
| 297 | {t("diag.gotoSettings")} |
| 298 | </button> |
| 299 | ) : null} |
| 300 | </article> |
| 301 | ))} |
| 302 | </div> |
| 303 | ), |
| 304 | )} |
| 305 | </div> |
| 306 | )} |
| 307 | </section> |
| 308 | |
| 309 | <Collapsible |
| 310 | title={t("diag.instructions")} |
| 311 | count={report.instructions.docs.length} |
| 312 | open={!!open.instructions} |
| 313 | onToggle={() => toggle("instructions")} |
| 314 | > |
| 315 | {report.instructions.docs.map((d) => ( |
| 316 | <div key={`${d.order}-${d.path}`} className="diag-row"> |
| 317 | <span>{d.order}. [{d.scope} · {d.depth}]</span> |
| 318 | <span className="diag-path">{d.path}</span> |
| 319 | </div> |
| 320 | ))} |
| 321 | {report.instructions.docs.length === 0 && <div className="empty">{t("common.none")}</div>} |
| 322 | </Collapsible> |
| 323 | |
| 324 | <Collapsible |
| 325 | title={t("diag.skills")} |
| 326 | count={report.skills.winners} |
| 327 | open={!!open.skills} |
| 328 | onToggle={() => toggle("skills")} |
| 329 | > |
| 330 | {report.skills.entries.filter((e) => e.status === "winner").map((e) => ( |
| 331 | <div key={`${e.name}-${e.path}`} className="diag-row"> |
| 332 | <span>{e.name}</span> |
| 333 | <span className="diag-path">{e.path}</span> |
| 334 | </div> |
| 335 | ))} |
| 336 | </Collapsible> |
| 337 | |
| 338 | <Collapsible |
| 339 | title={t("diag.commands")} |
| 340 | count={report.commands.winners} |
| 341 | open={!!open.commands} |
| 342 | onToggle={() => toggle("commands")} |
| 343 | > |
| 344 | {report.commands.entries.filter((e) => e.status === "winner").map((e) => ( |
| 345 | <div key={`${e.name}-${e.path}`} className="diag-row"> |
| 346 | <span>/{e.name}</span> |
| 347 | <span className="diag-path">{e.path}</span> |
| 348 | </div> |
| 349 | ))} |
| 350 | </Collapsible> |
| 351 | |
| 352 | <Collapsible |
| 353 | title={t("diag.hooks")} |
| 354 | count={report.hooks.entries.length} |
| 355 | open={!!open.hooks} |
| 356 | onToggle={() => toggle("hooks")} |
| 357 | > |
| 358 | {report.hooks.entries.map((e, i) => ( |
| 359 | <div key={`${e.event}-${e.source}-${i}`} className="diag-row"> |
| 360 | <span>{e.event} [{e.scope}]</span> |
| 361 | <span className="diag-path">{e.source}</span> |
| 362 | </div> |
| 363 | ))} |
| 364 | </Collapsible> |
| 365 | |
| 366 | <Collapsible |
| 367 | title={t("diag.plugins")} |
| 368 | count={report.plugins.packages.length} |
| 369 | open={!!open.plugins} |
| 370 | onToggle={() => toggle("plugins")} |
| 371 | > |
| 372 | {report.plugins.packages.map((p) => ( |
| 373 | <div key={p.name} className="diag-row"> |
| 374 | <span>{p.name} ({p.status})</span> |
| 375 | <span className="diag-path">{p.root}</span> |
| 376 | </div> |
| 377 | ))} |
| 378 | </Collapsible> |
| 379 | |
| 380 | <Collapsible |
| 381 | title={t("diag.mcp")} |
| 382 | count={report.mcp.servers.length} |
| 383 | open={!!open.mcp} |
| 384 | onToggle={() => toggle("mcp")} |
| 385 | > |
| 386 | {report.mcp.servers.map((s) => ( |
| 387 | <div key={s.name} className="diag-row"> |
| 388 | <span> |
| 389 | {s.name} · {s.transport} · {s.start_intent} |
| 390 | {s.runtime_status ? ` · ${s.runtime_status}` : ""} |
| 391 | </span> |
| 392 | <span className="diag-path">{s.source || s.command || s.url_host || ""}</span> |
| 393 | </div> |
| 394 | ))} |
| 395 | </Collapsible> |
| 396 | </> |
| 397 | )} |
| 398 | </div> |
| 399 | ); |
| 400 | } |
| 401 | |
| 402 | function normalizeDiagnosticsReport(report: CapabilityDiagnosticsReport): CapabilityDiagnosticsReport { |
| 403 | return { |
| 404 | ...report, |
| 405 | issues: asArray(report.issues), |
| 406 | instructions: { ...report.instructions, docs: asArray(report.instructions?.docs) }, |
| 407 | skills: { |
| 408 | ...report.skills, |
| 409 | roots: asArray(report.skills?.roots), |
| 410 | entries: asArray(report.skills?.entries), |
| 411 | }, |
| 412 | commands: { |
| 413 | ...report.commands, |
| 414 | roots: asArray(report.commands?.roots), |
| 415 | entries: asArray(report.commands?.entries), |
| 416 | }, |
| 417 | hooks: { |
| 418 | ...report.hooks, |
| 419 | sources: asArray(report.hooks?.sources), |
| 420 | entries: asArray(report.hooks?.entries), |
| 421 | }, |
| 422 | plugins: { ...report.plugins, packages: asArray(report.plugins?.packages) }, |
| 423 | mcp: { ...report.mcp, servers: asArray(report.mcp?.servers) }, |
| 424 | }; |
| 425 | } |
| 426 | |
| 427 | function Collapsible({ |
| 428 | title, |
| 429 | count, |
| 430 | open, |
| 431 | onToggle, |
| 432 | children, |
| 433 | }: { |
| 434 | title: string; |
| 435 | count: number; |
| 436 | open: boolean; |
| 437 | onToggle: () => void; |
| 438 | children: ReactNode; |
| 439 | }) { |
| 440 | return ( |
| 441 | <section className="diag-section"> |
| 442 | <button type="button" className="diag-section__header" onClick={onToggle}> |
| 443 | {open ? <ChevronDown size={16} /> : <ChevronRight size={16} />} |
| 444 | <span> |
| 445 | {title} ({count}) |
| 446 | </span> |
| 447 | </button> |
| 448 | {open && <div className="diag-section__body">{children}</div>} |
| 449 | </section> |
| 450 | ); |
| 451 | } |
| 452 |