feat(studio): the unparseable-config banner is the recovery board's card at the top of the settings page, with the offending line, the repair and a backed-up repair action

This commit is contained in:
yhh committed 2026-11-06 01:59:03 -08:00
1 parent f05b2392c3
commit 0e04e0d753
6 files changed
+142 -31

No files matched your search

@@ -4,6 +4,7 @@
@import "./rows.css" layer(design);
@import "./queue.css" layer(design);
@import "./notices.css" layer(design);
@import "./recovery.css" layer(design);
@import "./shell.css" layer(design);
@import "./composer.css" layer(design);
@import "./turn.css" layer(design);
@@ -0,0 +1,26 @@
.cfgbad { margin: 10px 0 14px; font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); }
.cfgbad .cfg-busy { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 10px 12px; border-radius: var(--r-md); background: var(--page); color: var(--muted); font-size: var(--fs-meta); }
.cfgbad .cfg-busy .studio-icon { width: 14px; height: 14px; }
.cfgbad .cfg-card { overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--raised); }
.cfgbad .cfg-hd { display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--hair); }
.cfgbad .cfg-card[data-lvl="err"] .cfg-hd { background: color-mix(in srgb, var(--err) 8%, transparent); }
.cfgbad .cfg-card[data-lvl="warn"] .cfg-hd { background: color-mix(in srgb, var(--warn) 12%, transparent); }
.cfgbad .cfg-hd > .studio-icon { flex: none; width: 16px; height: 16px; margin-top: 1px; }
.cfgbad .cfg-card[data-lvl="err"] .cfg-hd > .studio-icon { color: var(--err-ink); }
.cfgbad .cfg-card[data-lvl="warn"] .cfg-hd > .studio-icon { color: var(--warn-ink); }
.cfgbad .cfg-hd > div { flex: 1; min-width: 0; }
.cfgbad .cfg-hd b { font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); }
.cfgbad .cfg-hd .sub { margin-top: 2px; color: var(--muted); font-size: var(--fs-meta); }
.cfgbad .cfg-bd { padding: 10px 14px; }
.cfgbad .cfg-path { display: flex; align-items: center; gap: 8px; color: var(--muted); font: var(--w-reg) var(--fs-meta) var(--mono); }
.cfgbad .cfg-path > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.cfgbad .cfg-diff { margin-top: 8px; overflow: hidden; border: 1px solid var(--hair); border-radius: var(--r-sm); font: var(--w-reg) var(--fs-meta)/1.8 var(--mono); }
.cfgbad .cfg-diff > div { display: flex; white-space: pre-wrap; overflow-wrap: anywhere; }
.cfgbad .cfg-diff > div > span { flex: none; width: 30px; padding-right: 8px; text-align: right; color: var(--faint); }
.cfgbad .cfg-diff .er { background: color-mix(in srgb, var(--err) 10%, transparent); }
.cfgbad .cfg-diff .ok { background: color-mix(in srgb, var(--ok) 10%, transparent); }
.cfgbad .cfg-ft { display: flex; justify-content: flex-end; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--hair); }
.cfgbad .cfg-ft .btn { height: 32px; padding: 0 14px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1 var(--ui); cursor: pointer; }
.cfgbad .cfg-ft .btn[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
.cfgbad .cfg-ft .btn:disabled { opacity: .55; cursor: default; }
.cfgbad .cfg-fail { padding: 8px 14px; border-top: 1px solid var(--hair); color: var(--err-ink); font-size: var(--fs-meta); }
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import css from "./recovery.css?raw";
const rule = (selector: string) => {
const at = css.indexOf(`${selector} {`);
return at < 0 ? "" : css.slice(css.indexOf("{", at) + 1, css.indexOf("}", at));
};
describe("the config banner follows the board", () => {
it("is a hairline card with a tinted header and a footer rule", () => {
expect(rule(".cfgbad .cfg-card")).toMatch(/border-radius:\s*var\(--r-md\)/);
expect(rule(".cfgbad .cfg-hd")).toMatch(/padding:\s*12px 14px/);
expect(rule(".cfgbad .cfg-card[data-lvl=\"err\"] .cfg-hd")).toMatch(/var\(--err\) 8%/);
expect(rule(".cfgbad .cfg-card[data-lvl=\"warn\"] .cfg-hd")).toMatch(/var\(--warn\) 12%/);
expect(rule(".cfgbad .cfg-ft")).toMatch(/border-top:\s*1px solid var\(--hair\)/);
});
it("sets the diff in the mono face with a 30px line gutter and error / ok tints", () => {
expect(rule(".cfgbad .cfg-diff")).toMatch(/var\(--mono\)/);
expect(rule(".cfgbad .cfg-diff > div > span")).toMatch(/width:\s*30px/);
expect(rule(".cfgbad .cfg-diff .er")).toMatch(/var\(--err\) 10%/);
expect(rule(".cfgbad .cfg-diff .ok")).toMatch(/var\(--ok\) 10%/);
});
});
@@ -0,0 +1,57 @@
// @vitest-environment jsdom
import "./testkit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConfigTrouble } from "./ConfigTrouble";
import type { AgentPort, ConfigProblem } from "../port/port";
afterEach(cleanup);
const problem = (over: Partial<ConfigProblem> = {}): ConfigProblem => ({
path: "~/.reasonix/config.toml", line: 12, excerpt: "model = deepseek-v4-pro", recovered: "last-known-good", ...over,
}) as ConfigProblem;
function draw(p: ConfigProblem | null, repair = vi.fn(async () => ({ problem: null }))) {
const port = { configProblem: async () => p, repairConfig: repair } as unknown as AgentPort;
const onRepaired = vi.fn();
const view = render(<ConfigTrouble port={port} onRepaired={onRepaired} />);
return { view, repair, onRepaired };
}
describe("the config banner at the top of settings", () => {
it("says nothing when the file parses", async () => {
const { view } = draw(null);
await Promise.resolve();
expect(view.container.querySelector(".cfgbad")).toBeNull();
});
it("names the line, what is shown instead, the path and the offending text", async () => {
draw(problem());
const bar = await waitFor(() => { const e = document.querySelector(".cfgbad"); expect(e).toBeTruthy(); return e!; });
expect(bar.querySelector(".cfg-hd b")?.textContent).toBe("配置文件第 12 行无法解析");
expect(bar.querySelector(".cfg-hd .sub")?.textContent).toContain("上一次成功解析的配置");
expect(bar.querySelector(".cfg-path span")?.textContent).toBe("~/.reasonix/config.toml");
expect(bar.querySelector(".cfg-diff .er")?.textContent).toBe("12model = deepseek-v4-pro");
expect(bar.querySelector(".cfg-card")?.getAttribute("data-lvl")).toBe("warn");
expect(bar.querySelector(".cfg-ft")).toBeNull();
});
it("offers the repair as the main action only when the file parses afterwards", async () => {
const { repair, onRepaired } = draw(problem({ repair: 'model = "deepseek-v4-pro"' }));
const button = await screen.findByRole("button", { name: "备份原文件并修复" });
expect(button.hasAttribute("data-primary")).toBe(true);
expect(document.querySelector(".cfg-card")?.getAttribute("data-lvl")).toBe("err");
expect(document.querySelector(".cfg-diff .ok")?.textContent).toBe('改成model = "deepseek-v4-pro"');
await userEvent.click(button);
expect(repair).toHaveBeenCalledOnce();
await waitFor(() => expect(onRepaired).toHaveBeenCalled());
});
it("shows the refusal when the repair fails", async () => {
const repair = vi.fn(async () => { throw new Error("disk is read-only"); });
draw(problem({ repair: 'model = "x"' }), repair as never);
await userEvent.click(await screen.findByRole("button", { name: "备份原文件并修复" }));
expect((await screen.findByRole("alert")).textContent).toContain("disk is read-only");
});
});
+33 -30
View File
@@ -3,6 +3,7 @@ import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort, ConfigProblem } from "../port/port";
import { CopyButton } from "./CopyButton";
import { StudioIcon } from "./StudioIcon";
// The file every panel here writes to, when it is the file that is wrong.
//
@@ -43,41 +44,43 @@ export function ConfigTrouble({ port, onRepaired }: { port: AgentPort; onRepaire
}
};
const repairable = !!problem.repair;
return (
<div className="find cfgbad" data-lvl="warn" role="status">
<span className="t">
{problem.line ? t("app.line_line_of_the_config", { line: problem.line }) : t("app.the_config_file_does_not")}
</span>
<span className="why">
{problem.recovered === "last-known-good"
? t("app.what_is_shown_below_is")
: t("app.what_is_shown_below_is_2")}
</span>
<div className="term">
<div className="term-l dim">{problem.path}</div>
{problem.excerpt && (
<div className="term-l er">
{problem.line} | {problem.excerpt}
<div className="cfgbad" role="status">
{busy && <div className="cfg-busy"><StudioIcon name="refresh" />{t("app.repairing")}</div>}
<div className="cfg-card" data-lvl={repairable ? "err" : "warn"}>
<div className="cfg-hd">
<StudioIcon name={repairable ? "fail" : "warning"} />
<div>
<b>{problem.line ? t("app.line_line_of_the_config", { line: problem.line }) : t("app.the_config_file_does_not")}</b>
<div className="sub">
{problem.recovered === "last-known-good"
? t("app.what_is_shown_below_is")
: t("app.what_is_shown_below_is_2")}
</div>
</div>
)}
{/* The repair is the same bytes said in a way TOML accepts, not a
guess at what was meant — it is only offered when the file parses
afterwards. */}
{problem.repair && (
<div className="term-l ok">
{t("app.becomes")} | {problem.repair}
</div>
<div className="cfg-bd">
<div className="cfg-path"><span>{problem.path}</span><CopyButton text={problem.path} label={t("common.copy_path")} /></div>
{problem.excerpt && (
<div className="cfg-diff">
<div className="er"><span>{problem.line}</span>{problem.excerpt}</div>
{/* The repair is the same bytes said in a way TOML accepts, not a
guess at what was meant: it is only offered when the file parses
afterwards. */}
{problem.repair && <div className="ok"><span>{t("app.becomes")}</span>{problem.repair}</div>}
</div>
)}
</div>
{repairable && (
<div className="cfg-ft">
<button className="btn" data-primary="" data-action="config.repair" disabled={busy} onClick={() => void repair()}>
{t("app.keep_a_copy_and_repair")}
</button>
</div>
)}
{failed && <div className="cfg-fail" role="alert">{failed}</div>}
</div>
<div className="acts">
{problem.repair && (
<button className="act" data-action="config.repair" disabled={busy} onClick={() => void repair()}>
{busy ? t("app.repairing") : t("app.keep_a_copy_and_repair")}
</button>
)}
<CopyButton text={problem.path} label={t("common.copy_path")} />
</div>
{failed && <span className="why nwhy">{failed}</span>}
</div>
);
}
+1 -1
View File
@@ -408,11 +408,11 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
</div>
)}
</div>
<ConfigTrouble port={port} onRepaired={onChanged} />
<div className="prefs-body">
{!solo && <SettingsNav at={at} values={nav} shown={shown} danger={danger} disabled={updatingPkg.applying} onSelect={(section) => { if (section !== at || deepAt) navigation.leave(() => { setLanded(""); setDeep(null); setAt(section); }); }} page={pageNow} onPage={(section, page) => navigation.leave(() => { setLanded(""); setPages((p) => ({ ...p, [section]: page })); })} />}
<div className="prefs-main" key={at} role="tabpanel" aria-labelledby={pageNow ? `prefs-${at}-${pageNow}` : `prefs-${at}`} data-sec={at}>
<ConfigTrouble port={port} onRepaired={onChanged} />
{!solo && <SettingsTitle at={at} />}
<Boundary
fallback={