feat(studio): update, size-ask and stall notices use the board's card; the unsaved-preset dialog lists what changed; settings-did-not-open is a centred placeholder; notices past three fade out

This commit is contained in:
yhh committed 2026-11-06 01:55:41 -08:00
1 parent e41bddb590
commit f05b2392c3
13 files changed
+223 -47

No files matched your search

+46 -11
View File
@@ -7,15 +7,50 @@
.errbar:not([data-cause]) > button:hover { background: var(--overlay); color: var(--text); }
.errbar:not([data-cause]) > button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.compose > .composenotes { position: absolute; left: 0; right: 0; bottom: calc(100% + 8px); width: auto; max-height: none; margin: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; overflow: visible; pointer-events: none; z-index: 5; }
.compose > .composenotes > * { margin: 0; pointer-events: auto; }
.compose > .composenotes > :nth-last-child(n+4) { display: none; }
.compose > .composenotes > * { margin: 0; pointer-events: auto; max-height: 40rem; transition: opacity var(--t-card) var(--ease), max-height var(--t-card) var(--ease), margin var(--t-card) var(--ease), padding var(--t-card) var(--ease); }
.compose > .composenotes > :nth-last-child(n+4) { max-height: 0; margin-block: -4px; padding-block: 0; border-width: 0; opacity: 0; overflow: hidden; pointer-events: none; }
.composenotes > .errbar:not([data-cause]) { position: static; z-index: auto; }
.rtbar:not([data-kind], .stallbar) { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0 10px; width: 380px; max-width: calc(100% - 24px); box-sizing: border-box; margin: 0; padding: 10px 10px 10px 12px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); color: var(--text); }
.rtbar:not([data-kind], .stallbar)[data-lvl="warn"] { border-color: var(--border); background: var(--float); color: var(--text); }
.rtbar:not([data-kind], .stallbar) > .studio-icon { flex: none; width: 16px; height: 16px; margin-top: 1px; color: var(--muted); }
.rtbar:not([data-kind], .stallbar)[data-lvl="warn"] > .studio-icon { color: var(--warn-ink); }
.rtbar:not([data-kind], .stallbar) > .t { flex: 1 1 0; min-width: 0; font-weight: var(--w-reg); }
.rtbar:not([data-kind], .stallbar) > .why { order: 1; flex: 1 0 100%; padding-left: 26px; color: var(--muted); font-size: var(--fs-meta); }
.rtbar:not([data-kind], .stallbar) > button { flex: none; height: 24px; margin: -4px 0; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1 var(--ui); text-decoration: none; white-space: nowrap; cursor: pointer; }
.rtbar:not([data-kind], .stallbar) > button:hover { background: var(--overlay); color: var(--text); }
.rtbar:not([data-kind], .stallbar) > button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.rtbar:not([data-kind]) { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0 10px; width: 380px; max-width: calc(100% - 24px); box-sizing: border-box; margin: 0; padding: 10px 10px 10px 12px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); color: var(--text); }
.rtbar:not([data-kind])[data-lvl="warn"] { border-color: var(--border); background: var(--float); color: var(--text); }
.rtbar:not([data-kind]) > .studio-icon { flex: none; width: 16px; height: 16px; margin-top: 1px; color: var(--muted); }
.rtbar:not([data-kind])[data-lvl="warn"] > .studio-icon { color: var(--warn-ink); }
.rtbar:not([data-kind]) > .t { flex: 1 1 0; min-width: 0; font-weight: var(--w-reg); }
.rtbar:not([data-kind]) > .why { order: 1; flex: 1 0 100%; padding-left: 26px; color: var(--muted); font-size: var(--fs-meta); }
.rtbar:not([data-kind]) > button { flex: none; height: 24px; margin: -4px 0; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1 var(--ui); text-decoration: none; white-space: nowrap; cursor: pointer; }
.rtbar:not([data-kind]) > button:hover { background: var(--overlay); color: var(--text); }
.rtbar:not([data-kind]) > button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.toast { align-items: flex-start; width: 380px; max-width: 100%; padding: 10px 10px 10px 12px; background: var(--float); box-shadow: var(--shadow-float); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); }
.toast-icon { margin-top: 1px; }
.toast-body > b { font-weight: var(--w-semi); }
.toast-body > .sub { color: var(--muted); font-size: var(--fs-meta); }
.toast-actions { gap: 2px; margin: -4px 0; }
.toast-actions .act { 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); }
.toast-actions .act:hover:not(:disabled) { background: var(--overlay); }
.toast-actions .act.ghost { height: 24px; padding: 0 10px; border-color: transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1 var(--ui); }
.toast-actions .act.ghost:hover:not(:disabled) { background: var(--overlay); color: var(--text); }
.toast-actions .act[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
.toast[data-stacked] .toast-actions { margin: 4px 0 0 26px; gap: 6px; }
.preset-confirm-veil { background: color-mix(in oklab, var(--page) 80%, transparent); }
.preset-confirm { width: 380px; max-width: 100%; padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); }
.preset-confirm > header { display: flex; align-items: center; gap: 8px; padding: 14px 16px 0; }
.preset-confirm > header h3 { flex: 1; margin: 0; font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); }
.preset-confirm .pc-x { width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); display: inline-grid; place-items: center; flex: none; cursor: pointer; }
.preset-confirm .pc-x:hover { background: var(--overlay); color: var(--text); }
.preset-confirm .pc-body { padding: 10px 16px 14px; }
.preset-confirm .pc-body p { margin: 0; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.7 var(--ui); }
.preset-confirm .pc-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.preset-confirm .pc-chips span { display: inline-grid; place-items: center; height: 20px; padding: 0 7px; border-radius: var(--r-pill); background: var(--overlay); color: var(--muted); font: var(--w-reg) var(--fs-micro)/1 var(--ui); white-space: nowrap; }
.preset-confirm .preset-confirm-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--hair); }
.preset-confirm .preset-confirm-actions .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); }
.preset-confirm .preset-confirm-actions .btn[data-danger] { color: var(--err-ink); }
.preset-confirm .preset-confirm-actions .btn[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
.prefs-sheet.gone { width: 440px; max-width: calc(100% - 32px); height: auto; margin: auto; padding: 22px 20px; text-align: center; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); }
.prefs-sheet.gone .gone-icon { display: inline-grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: color-mix(in srgb, var(--warn) 14%, transparent); color: var(--warn-ink); }
.prefs-sheet.gone h1 { margin: 10px 0 0; font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); }
.prefs-sheet.gone .gone-t { margin: 4px 0 0; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); }
.prefs-sheet.gone .gone-why { max-width: 340px; margin: 10px auto 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.7 var(--ui); }
.prefs-sheet.gone .gone-acts { display: flex; justify-content: center; gap: 8px; margin-top: 14px; }
.prefs-sheet.gone .gone-acts .btn { display: inline-flex; align-items: center; gap: 5px; 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; }
.prefs-sheet.gone .gone-acts .btn.ghost { height: 24px; padding: 0 10px; align-self: center; border-color: transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font-size: var(--fs-meta); }
.prefs-sheet.gone .gone-acts .btn[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
.prefs-sheet.gone .studio-icon { width: 16px; height: 16px; }
@@ -26,6 +26,22 @@ describe("the notice strip follows the board", () => {
const stack = rule(".compose > .composenotes");
expect(stack).toMatch(/bottom:\s*calc\(100% \+ 8px\)/);
expect(stack).toMatch(/align-items:\s*flex-end/);
expect(css).toContain(".compose > .composenotes > :nth-last-child(n+4) { display: none; }");
expect(rule(".compose > .composenotes > :nth-last-child(n+4)")).toMatch(/opacity:\s*0/);
expect(rule(".compose > .composenotes > :nth-last-child(n+4)")).toMatch(/max-height:\s*0/);
expect(rule(".compose > .composenotes > *")).toMatch(/transition:/);
});
it("gives the toast row the board's card, its three action levels and the update's primary button", () => {
expect(rule(".toast")).toMatch(/width:\s*380px/);
expect(rule(".toast")).toMatch(/background:\s*var\(--float\)/);
expect(rule(".toast-actions .act")).toMatch(/height:\s*32px/);
expect(rule(".toast-actions .act.ghost")).toMatch(/height:\s*24px/);
expect(rule(".toast-actions .act[data-primary]")).toMatch(/background:\s*var\(--accent\)/);
});
it("draws the leave dialog at 380 with its footer rule, and the settings placeholder at 440", () => {
expect(rule(".preset-confirm")).toMatch(/width:\s*380px/);
expect(rule(".preset-confirm .preset-confirm-actions")).toMatch(/border-top:\s*1px solid var\(--hair\)/);
expect(rule(".prefs-sheet.gone")).toMatch(/width:\s*440px/);
});
});
+2 -1
View File
@@ -91,7 +91,8 @@ export const ABOUT: Messages = {
"about.verifying_the_signature": { zh: "校验签名…", en: "Verifying the signature…" },
"about.version_information_could_not_be": { zh: "无法读取版本信息", en: "Version information could not be read" },
"about.version_v_is_available_see": { zh: "有新版本 {v},在设置的「版本」里查看", en: "Version {v} is available; see Settings, Version" },
"about.version_v_is_downloaded_restart": { zh: "新版本 {v} 已下载。重启后使用,不会自动重启。", en: "Version {v} is downloaded. Restart to use it; Studio never restarts by itself." },
"about.version_v_is_downloaded": { zh: "新版本 {v} 已下载。", en: "Version {v} is downloaded." },
"about.restart_to_use_it_never_automatic": { zh: "重启后使用,不会自动重启。", en: "Restart to use it; Studio never restarts by itself." },
"about.view_on_github": { zh: "在 GitHub 查看", en: "View on GitHub" },
"about.waiting_for_permission": { zh: "等待系统授权…", en: "Waiting for permission…" },
"about.whats_new": { zh: "更新内容", en: "What's new" },
@@ -73,6 +73,7 @@ export const PERSONA: Messages = {
"persona.no_matching_service": { zh: "没有匹配的服务", en: "No matching service" },
"persona.no_modules_available": { zh: "暂无可选模块", en: "No modules available" },
"persona.no_preset_applied_fill_in": { zh: "未应用预设;直接填写下面的两段即可。", en: "No preset applied; fill in the two boxes below." },
"persona.n_kind_items": { zh: "{kind} · {n} 项", en: "{kind} · {n}" },
"persona.off_by_default_enable_to": { zh: "默认关闭。开启后在聊天输入框顶部显示模式选择器;关闭只隐藏入口,不改变会话模式。", en: "Off by default. Enable to show the mode picker above the chat input. Turning it off hides the picker without changing the conversation's mode." },
"persona.page_the_agent_is_viewing": { zh: "智能体正在查看的页面", en: "Page the agent is viewing" },
"persona.parameters": { zh: "独立参数", en: "Parameters" },
@@ -12,15 +12,9 @@ import { COMPOSITION_KINDS } from "../port/composition";
import { Switch } from "./Switch";
import { StudioIcon } from "./StudioIcon";
import { CompositionModulePicker } from "./CompositionModulePicker";
import { KIND_LABELS } from "./presetDraft";
const labels: Record<CompositionKind, string> = {
tools: "common.tool",
skills: "settings.skills",
mcp: "persona.mcp_servers",
extensions: "ext.extension",
hooks: "persona.automation_hooks",
commands: "persona.slash_commands",
};
const labels = KIND_LABELS;
export function CompositionEditor({
draft,
+14 -7
View File
@@ -3,9 +3,10 @@ import { createPortal } from "react-dom";
import { t } from "../i18n";
import { listenAction } from "./listen";
import { useModalFocus } from "./useModalFocus";
import { StudioIcon } from "./StudioIcon";
import { useSettingsLeaveGuard } from "./SettingsLeave";
export function usePresetLeave(dirty: boolean, busy: boolean) {
export function usePresetLeave(dirty: boolean, busy: boolean, changes: string[] = []) {
const [asked, setAsked] = useState<{ leave: () => void; from: HTMLElement | null } | null>(null);
const request = useCallback((leave: () => void) => {
if (busy) return;
@@ -28,7 +29,7 @@ export function usePresetLeave(dirty: boolean, busy: boolean) {
setAsked(null);
if (from?.isConnected) from.focus();
};
const confirmation = asked && <PresetLeaveDialog onKeep={keep} onDiscard={() => {
const confirmation = asked && <PresetLeaveDialog changes={changes} onKeep={keep} onDiscard={() => {
const leave = asked.leave;
setAsked(null);
leave();
@@ -36,12 +37,12 @@ export function usePresetLeave(dirty: boolean, busy: boolean) {
return { request, confirmation };
}
function PresetLeaveDialog({ onKeep, onDiscard }: { onKeep: () => void; onDiscard: () => void }) {
function PresetLeaveDialog({ changes, onKeep, onDiscard }: { changes: string[]; onKeep: () => void; onDiscard: () => void }) {
const root = useRef<HTMLDivElement>(null);
const title = useId();
useModalFocus(root);
useEffect(() => {
root.current?.querySelector<HTMLButtonElement>("button")?.focus();
root.current?.querySelector<HTMLButtonElement>("[data-primary]")?.focus();
return listenAction(window, "keydown", { action: "agent-preset.leave-cancel", listener: (event) => {
if ((event as KeyboardEvent).key !== "Escape") return;
event.preventDefault();
@@ -51,11 +52,17 @@ function PresetLeaveDialog({ onKeep, onDiscard }: { onKeep: () => void; onDiscar
}, [onKeep]);
return createPortal(<div className="preset-confirm-veil">
<div className="preset-confirm" ref={root} role="alertdialog" aria-modal="true" aria-labelledby={title}>
<h3 id={title}>{t("persona.discard_unsaved_changes")}</h3>
<p>{t("common.they_will_be_lost_if")}</p>
<header>
<h3 id={title}>{t("persona.discard_unsaved_changes")}</h3>
<button type="button" className="pc-x" data-action="agent-preset.leave-cancel" aria-label={t("common.close")} title={t("common.close")} onClick={onKeep}><StudioIcon name="close" /></button>
</header>
<div className="pc-body">
<p>{t("common.they_will_be_lost_if")}</p>
{changes.length > 0 && <div className="pc-chips">{changes.map((c) => <span key={c}>{c}</span>)}</div>}
</div>
<div className="preset-confirm-actions">
<button className="btn" data-action="agent-preset.leave-cancel" onClick={onKeep}>{t("persona.keep_editing")}</button>
<button className="btn" data-danger="" data-action="agent-preset.leave-discard" onClick={onDiscard}>{t("persona.discard_changes")}</button>
<button className="btn" data-primary="" data-action="agent-preset.leave-cancel" onClick={onKeep}>{t("persona.keep_editing")}</button>
</div>
</div>
</div>, document.body);
+2 -2
View File
@@ -6,7 +6,7 @@ import { reason, say } from "../i18n/kernel";
import { CompositionEditor } from "./CompositionEditor";
import { PresetActions } from "./PresetActions";
import { usePresetLeave } from "./PresetLeave";
import { samePreset } from "./presetDraft";
import { presetChanges, samePreset } from "./presetDraft";
import { StudioIcon } from "./StudioIcon";
export interface PresetEntry {
@@ -35,7 +35,7 @@ export function PresetPage({ entry, catalog, port, onClose, onSaved, onDeleted,
const immutable = draft.id.startsWith("builtin:");
const changed = !samePreset(draft, entry.definition);
const dirty = !immutable && (changed || !valid);
const leave = usePresetLeave(dirty, busy);
const leave = usePresetLeave(dirty, busy, presetChanges(draft, entry.definition));
const title = t(immutable ? "persona.preset_details" : entry.version ? "persona.edit_preset" : "persona.new_preset");
const canSave = !immutable && !busy && valid && !!draft.name.trim() &&
/^(user|project):[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(draft.id) && (!entry.version || changed);
@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import "./testkit";
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SettingsUnavailable } from "./SettingsUnavailable";
afterEach(cleanup);
it("says what failed, why, and what it spares, with reload as the main action", () => {
render(<SettingsUnavailable onClose={() => {}} />);
const dialog = screen.getByRole("alertdialog");
expect(dialog.querySelector("h1")?.textContent).toBe("设置没能打开");
expect(dialog.textContent).toContain("这一页是单独取的,这次没取到");
expect(dialog.textContent).toContain("当前会话与正在跑的回合都不受影响");
const actions = [...dialog.querySelectorAll(".gone-acts button")];
expect(actions.map((b) => b.textContent)).toEqual(["关闭", "重新载入"]);
expect(actions[1].hasAttribute("data-primary")).toBe(true);
});
it("closes through the ghost action", async () => {
const onClose = vi.fn();
render(<SettingsUnavailable onClose={onClose} />);
await userEvent.click(screen.getByText("关闭"));
expect(onClose).toHaveBeenCalledOnce();
});
@@ -1,6 +1,7 @@
import { useRef } from "react";
import { useModalFocus } from "./useModalFocus";
import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon";
// What is shown when the settings chunk does not arrive. An update replaces the
// assets under a window still holding the old document, so the name it asks for
@@ -11,21 +12,14 @@ export function SettingsUnavailable({ onClose }: { onClose: () => void }) {
useModalFocus(sheet);
return (
<div className="prefs">
<div ref={sheet} className="prefs-sheet" role="alertdialog" aria-modal="true" aria-labelledby="prefs-gone">
<div className="prefs-hd">
<h1 id="prefs-gone">{t("settings.settings_did_not_open")}</h1>
<button className="btn sm" data-action="chrome.reload" onClick={() => location.reload()}>
{t("settings.reload")}
</button>
<button className="btn sm" data-action="settings.close" onClick={onClose}>
{t("common.close")}
</button>
</div>
<div className="find" data-lvl="err" role="alert">
<span className="t">{t("settings.this_screen_is_fetched_on")}</span>
<span className="why">
{t("settings.an_update_most_likely_replaced")}
</span>
<div ref={sheet} className="prefs-sheet gone" role="alertdialog" aria-modal="true" aria-labelledby="prefs-gone">
<span className="gone-icon" aria-hidden="true"><StudioIcon name="warning" /></span>
<h1 id="prefs-gone">{t("settings.settings_did_not_open")}</h1>
<p className="gone-t" role="alert">{t("settings.this_screen_is_fetched_on")}</p>
<p className="gone-why">{t("settings.an_update_most_likely_replaced")}</p>
<div className="gone-acts">
<button className="btn ghost" data-action="settings.close" onClick={onClose}>{t("common.close")}</button>
<button className="btn" data-primary="" data-action="chrome.reload" onClick={() => location.reload()}><StudioIcon name="refresh" />{t("settings.reload")}</button>
</div>
</div>
</div>
@@ -1,4 +1,5 @@
import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon";
import type { Stall } from "../state/session_types";
export type StallAction = "stop" | "mute" | "continue" | "dismiss";
@@ -21,6 +22,7 @@ export function StallBar({ stall, onAct }: { stall: Stall; onAct: (a: StallActio
: t("composer.no_file_changed_no_check");
return (
<div className="rtbar stallbar" data-lvl="warn" role="status" data-paused={stall.paused ? "" : undefined}>
<StudioIcon name="warning" />
<span className="t">{stall.paused ? t("composer.task_paused_s", { s: said }) : said}</span>
<span className="why" title={why}>{why}</span>
{stall.paused ? (
+3 -1
View File
@@ -29,6 +29,8 @@ export function UpdateReady({ port, idle }: { port: Pick<AgentPort, "restartToVe
<ToastRow kind="update" role="status" className="update-ready" actions={<>
<button className="act ghost" data-action="update.later" onClick={() => setPutOff(version)}>{t("about.later")}</button>
<button className="act" data-primary data-action="update.restart" onClick={() => void restart()}>{t("about.restart_and_update")}</button>
</>}>{says || t("about.version_v_is_downloaded_restart", { v: version })}</ToastRow>
</>}><b>{t("about.version_v_is_downloaded", { v: version })}</b>
<div className="sub">{says || t("about.restart_to_use_it_never_automatic")}</div>
</ToastRow>
);
}
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import "./testkit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { usePresetLeave } from "./PresetLeave";
import { builtinCompositions } from "../port/composition";
import { presetChanges } from "./presetDraft";
afterEach(cleanup);
function Host({ changes, leave }: { changes: string[]; leave: () => void }) {
const guard = usePresetLeave(true, false, changes);
return <>
<button onClick={() => guard.request(leave)}>back</button>
{guard.confirmation}
</>;
}
describe("leaving a preset with unsaved changes", () => {
it("lists what would be lost and makes keep-editing the main action", async () => {
const leave = vi.fn();
render(<Host changes={["预设名称", "人设与要求", "工具 · 3 项"]} leave={leave} />);
await userEvent.click(screen.getByText("back"));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain("放弃未保存的更改?");
expect([...dialog.querySelectorAll(".pc-chips span")].map((c) => c.textContent)).toEqual(["预设名称", "人设与要求", "工具 · 3 项"]);
expect(document.activeElement?.textContent).toBe("继续编辑");
const actions = [...dialog.querySelectorAll(".preset-confirm-actions button")].map((b) => b.textContent);
expect(actions).toEqual(["放弃更改", "继续编辑"]);
});
it("discards through the danger action and keeps through the main one and the close button", async () => {
const leave = vi.fn();
render(<Host changes={[]} leave={leave} />);
await userEvent.click(screen.getByText("back"));
await userEvent.click(await screen.findByRole("button", { name: "关闭" }));
expect(screen.queryByRole("alertdialog")).toBeNull();
expect(leave).not.toHaveBeenCalled();
await userEvent.click(screen.getByText("back"));
await userEvent.click(await screen.findByText("放弃更改"));
expect(leave).toHaveBeenCalledOnce();
});
it("draws no chip row when nothing is itemised", async () => {
render(<Host changes={[]} leave={() => {}} />);
await userEvent.click(screen.getByText("back"));
expect((await screen.findByRole("alertdialog")).querySelector(".pc-chips")).toBeNull();
});
});
describe("what changed in a preset", () => {
const base = () => builtinCompositions()[0];
it("is empty for an untouched draft", () => {
expect(presetChanges(base(), base())).toEqual([]);
});
it("names the parts the draft moved", () => {
const draft = base();
draft.name = "renamed";
draft.persona = { ...draft.persona, prefix: "be brief" };
draft.tools = { mode: "select", members: [{ id: "a" }, { id: "b" }, { id: "c" }] };
const out = presetChanges(draft, base());
expect(out).toContain("预设名称");
expect(out).toContain("人设与要求");
expect(out.some((c) => c.endsWith("· 3 项"))).toBe(true);
});
it("counts a renamed description with the name", () => {
const draft = base();
draft.description = "something else";
expect(presetChanges(draft, base())).toEqual(["预设名称"]);
});
});
+24 -1
View File
@@ -1,5 +1,5 @@
import { t } from "../i18n";
import type { AgentComposition, AgentPresetCatalog } from "../port/composition";
import { COMPOSITION_KINDS, type AgentComposition, type AgentPresetCatalog, type CompositionKind } from "../port/composition";
export function newPreset(base: AgentComposition, catalog: AgentPresetCatalog, copy: boolean): AgentComposition {
const ids = new Set(catalog.presets.map(v => v.definition.id));
@@ -14,3 +14,26 @@ export function newPreset(base: AgentComposition, catalog: AgentPresetCatalog, c
}
export const samePreset = (a: AgentComposition, b: AgentComposition) => JSON.stringify(a) === JSON.stringify(b);
export const KIND_LABELS: Record<CompositionKind, string> = {
tools: "common.tool",
skills: "settings.skills",
mcp: "persona.mcp_servers",
extensions: "ext.extension",
hooks: "persona.automation_hooks",
commands: "persona.slash_commands",
};
const differs = (a: unknown, b: unknown) => JSON.stringify(a) !== JSON.stringify(b);
/** Which parts of a preset the draft moved away from the saved one, worded the
* way the editor words them, so leaving can say what would be lost. */
export function presetChanges(draft: AgentComposition, saved: AgentComposition): string[] {
const out: string[] = [];
if (draft.name !== saved.name || draft.description !== saved.description) out.push(t("persona.preset_name"));
if (differs(draft.persona, saved.persona)) out.push(t("persona.persona_and_instructions"));
for (const kind of COMPOSITION_KINDS) {
if (differs(draft[kind], saved[kind])) out.push(t("persona.n_kind_items", { kind: t(KIND_LABELS[kind]), n: draft[kind].members.length }));
}
return out;
}