fix(studio): renew each runtime application confirmation

This commit is contained in:
xiexian committed 2026-10-06 09:03:06 +08:00
1 parent f1bae78d39
commit 20c883c724
2 files changed
+101 -19

No files matched your search

+11 -18
View File
@@ -21,52 +21,45 @@ export function useRuntimeReload(port: AgentPort, onDone: () => void) {
const currentConnection = useRef(connection);
currentConnection.current = connection;
const attempts = useRef(0);
const [state, setState] = useState<State>("");
const [note, setNote] = useState("");
const [feedback, setFeedback] = useState<{ state: State; note: string }>({ state: "", note: "" });
const { state, note } = feedback;
if (connection.port !== port) {
setConnection({ port });
setState("");
setNote("");
setFeedback({ state: "", note: "" });
}
const go = useCallback(async () => {
const attempt = ++attempts.current;
setState("run");
setNote(t("正在重启常驻进程,重新扫描技能、命令和钩子…"));
setFeedback({ state: "run", note: t("正在重启常驻进程,重新扫描技能、命令和钩子…") });
try {
await port.reloadExtensions();
if (currentConnection.current !== connection || attempts.current !== attempt) return;
setState("ok");
setNote(t("已生效,下一轮开始用新的扩展"));
setFeedback({ state: "ok", note: t("已生效,下一轮开始用新的扩展") });
onDone();
} catch (e) {
if (currentConnection.current !== connection || attempts.current !== attempt) return;
// A refusal is the kernel's, and it knows why: a turn in flight, a
// background job, a session that moved. Say its reason, not a generic one.
setState("bad");
setNote(reason(e));
setFeedback({ state: "bad", note: reason(e) });
}
}, [port, onDone, connection]);
useEffect(() => {
if (state !== "ok") return;
if (feedback.state !== "ok") return;
const id = setTimeout(() => {
setState("");
setNote("");
setFeedback({ state: "", note: "" });
}, SETTLED_MS);
return () => clearTimeout(id);
}, [state]);
}, [feedback]);
return {
applied: () => {
attempts.current++;
setState("ok");
setNote(t("已生效,下一轮开始用新的扩展"));
setFeedback({ state: "ok", note: t("已生效,下一轮开始用新的扩展") });
},
report: (message: string) => {
attempts.current++;
setState("bad");
setNote(t("更改已保存,运行时未重载:{reason}。请用「重载运行时」重试。", { reason: message }));
setFeedback({ state: "bad", note: t("更改已保存,运行时未重载:{reason}。请用「重载运行时」重试。", { reason: message }) });
},
action: (
<button className="act reload" data-action="extensions.reload" data-s={state || undefined} disabled={state === "run"} onClick={go}>
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, within } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Settings } from "./Settings";
@@ -12,6 +12,7 @@ import type { AgentPort, PluginPackage, SessionStatus } from "../port/port";
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
localStorage.setItem(STORAGE, "zh");
@@ -138,3 +139,91 @@ it("clears the earlier warning when a later saved toggle reloads successfully",
expect(calls.mock.calls.filter(([url]) => url.endsWith("/plugins/enabled"))).toHaveLength(2);
expect(calls.mock.calls.filter(([url]) => url.endsWith("/extensions/reload"))).toHaveLength(0);
});
async function timedSettings() {
const result = fixture(true);
const changed = vi.fn();
const view = render(draw(result.port, changed));
await screen.findByText("notes-kit");
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const click = (element: HTMLElement) => act(async () => { fireEvent.click(element); });
const toggle = () => click(within(row()).getByRole("switch"));
const advance = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); });
return { ...result, changed, view, click, toggle, advance };
}
it.each(["package", "manual"])("gives a later package application its own confirmation window after %s success", async (first) => {
const { click, toggle, advance, changed, calls } = await timedSettings();
if (first === "manual") await click(screen.getByRole("button", { name: t("重载运行时") }));
else await toggle();
expect(screen.getByText(t("已生效,下一轮开始用新的扩展"))).toBeTruthy();
await advance(3500);
await toggle();
expect(changed).toHaveBeenCalledTimes(2);
await advance(500);
expect(screen.getByText(t("已生效,下一轮开始用新的扩展"))).toBeTruthy();
expect(screen.getByRole<HTMLButtonElement>("button", { name: t("已生效") }).disabled).toBe(false);
await advance(3499);
expect(screen.getByText(t("已生效,下一轮开始用新的扩展"))).toBeTruthy();
await advance(1);
expect(screen.queryByText(t("已生效,下一轮开始用新的扩展"))).toBeNull();
expect(screen.getByRole<HTMLButtonElement>("button", { name: t("重载运行时") }).disabled).toBe(false);
expect(calls.mock.calls.filter(([url]) => url.endsWith("/plugins/enabled"))).toHaveLength(first === "manual" ? 1 : 2);
expect(calls.mock.calls.filter(([url]) => url.endsWith("/extensions/reload"))).toHaveLength(first === "manual" ? 1 : 0);
});
it("expires a single confirmation without extending it on a Settings rerender", async () => {
const { port, changed, view, toggle, advance } = await timedSettings();
await toggle();
await advance(3500);
view.rerender(draw(port, changed));
await advance(499);
expect(screen.getByText(t("已生效,下一轮开始用新的扩展"))).toBeTruthy();
await advance(1);
expect(screen.queryByText(t("已生效,下一轮开始用新的扩展"))).toBeNull();
expect(changed).toHaveBeenCalledTimes(1);
});
it("retains a later saved-but-not-reloaded warning past the old success expiry", async () => {
const { port, toggle, advance } = await timedSettings();
await toggle();
await advance(3500);
vi.spyOn(port, "setPluginEnabled").mockResolvedValueOnce({ reloadError: refusal });
await toggle();
await advance(10000);
expect(screen.getByText(t(warning, { reason: refusal }))).toBeTruthy();
expect(screen.getByRole<HTMLButtonElement>("button", { name: t("重载运行时") }).disabled).toBe(false);
});
it("keeps a later manual reload pending past the old success expiry", async () => {
const { port, click, toggle, advance, changed } = await timedSettings();
await toggle();
await advance(3500);
let finish!: () => void;
vi.spyOn(port, "reloadExtensions").mockImplementationOnce(() => new Promise<void>((resolve) => { finish = resolve; }));
await click(screen.getByRole("button", { name: t("已生效") }));
await advance(10000);
expect(screen.getByRole<HTMLButtonElement>("button", { name: t("重载中") }).disabled).toBe(true);
await act(async () => finish());
await advance(3999);
expect(screen.getByText(t("已生效,下一轮开始用新的扩展"))).toBeTruthy();
await advance(1);
expect(screen.queryByText(t("已生效,下一轮开始用新的扩展"))).toBeNull();
expect(changed).toHaveBeenCalledTimes(2);
});
it("does not let a previous connection's confirmation expire the new connection's success", async () => {
const { changed, view, click, toggle, advance } = await timedSettings();
await toggle();
await advance(3500);
const next = new MockPort() as unknown as AgentPort;
vi.spyOn(next, "reloadExtensions").mockResolvedValue();
await act(async () => view.rerender(draw(next, changed)));
expect(screen.queryByText(t("已生效,下一轮开始用新的扩展"))).toBeNull();
await click(screen.getByRole("button", { name: t("重载运行时") }));
await advance(3999);
expect(screen.getByText(t("已生效,下一轮开始用新的扩展"))).toBeTruthy();
await advance(1);
expect(screen.queryByText(t("已生效,下一轮开始用新的扩展"))).toBeNull();
expect(changed).toHaveBeenCalledTimes(2);
});