diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html
index 6f58989..38b573a 100644
--- a/internal/gateway/ui/index.html
+++ b/internal/gateway/ui/index.html
@@ -832,7 +832,7 @@
kAutoChainSave: "保存",
kAutoChainTier: "层级",
kAutoChainEmpty: "该密钥使用全局 AUTO 链",
- kAutoChainBad: "槽位无法解析(模型或源不存在),保存后 AUTO 请求会失败",
+ kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败",
kName: "名称",
kRole: "角色",
kRoleAdmin: "管理员",
@@ -1103,7 +1103,7 @@
kAutoChainSave: "Save",
kAutoChainTier: "Tier",
kAutoChainEmpty: "This key uses the global AUTO chain",
- kAutoChainBad: "Slot does not resolve (unknown model or source); AUTO requests would fail",
+ kAutoChainBad: "Saved, but the slot does not resolve (no source serves that model); AUTO requests will fail",
kName: "Name",
kRole: "Role",
kRoleAdmin: "Admin",
@@ -3694,9 +3694,17 @@ const pnote = $("#period-note");
// different id inside its modal, so both share one implementation
// instead of a reduced second copy that would drift.
function sortCanvasEl() {
- const own = document.getElementById("scr-canvas");
- if (own) return own;
- return document.getElementById("key-auto-canvas");
+ // The modal editor is mounted on top of the settings tab, so BOTH
+ // canvases can be in the DOM at once — #scr-canvas belongs to the
+ // hidden tab behind it. Resolve by scope, not by document order: when
+ // sortState.scope names a key we are editing THAT key's chain, and
+ // painting into the background tab's canvas both shows an empty
+ // dialog and corrupts the global chain the user comes back to.
+ if (sortState.scope) {
+ const k = document.getElementById("key-auto-canvas");
+ if (k) return k;
+ }
+ return document.getElementById("scr-canvas");
}
function paintSort(affected) {
const cv = sortCanvasEl();
@@ -5283,10 +5291,12 @@ const cv = sortCanvasEl();
body: JSON.stringify({ auto: lanesToRules(sortState.lanes) }),
});
if (j.inherits) {
- toast(t("kAutoChainGlobal"));
+toast(t("kAutoChainGlobal"));
} else if (!j.slots) {
- // Persisted, but nothing resolves — say so now instead of letting
- // the user meet it as a 503 on their next AUTO request.
+ // Persisted, but nothing resolves. Still close: the write
+ // SUCCEEDED, and leaving the dialog open after a successful save
+ // makes the user click Save repeatedly. The warning carries the
+ // real problem — the toast survives the modal closing.
toast(t("kAutoChainBad"));
} else {
toast(t("sortSaved") + " (" + j.slots + ")");
diff --git a/internal/gateway/ui_key_auto_test.go b/internal/gateway/ui_key_auto_test.go
index 2755b42..a9a4206 100644
--- a/internal/gateway/ui_key_auto_test.go
+++ b/internal/gateway/ui_key_auto_test.go
@@ -124,4 +124,83 @@ func TestUIStatsShowsPeriodWindow(t *testing.T) {
if n := strings.Count(src, "periodRange"); n < 2 {
t.Fatalf("i18n key periodRange appears %d time(s), want both zh and en", n)
}
-}
\ No newline at end of file
+}
+// Both canvases can exist in the DOM at once: the modal is mounted on top of
+// the settings tab, whose #scr-canvas stays alive behind it. Resolving the
+// canvas by document order (whichever comes first) painted the per-key chain
+// into the hidden background tab and left the dialog empty — and worse,
+// overwrote the global chain the user returns to. The lookup must follow
+// sortState.scope instead.
+func TestUISortCanvasResolvesByScope(t *testing.T) {
+ src := uiSource(t)
+ el := readFuncBody(t, src, "sortCanvasEl")
+ if !strings.Contains(el, "if (sortState.scope)") {
+ t.Fatal("sortCanvasEl must branch on sortState.scope, not document order")
+ }
+ if strings.Contains(el, `getElementById("scr-canvas")`) &&
+ strings.Index(el, `getElementById("scr-canvas")`) < strings.Index(el, "sortState.scope") {
+ t.Fatal("sortCanvasEl returns #scr-canvas before checking scope — the " +
+ "per-key editor paints into the global canvas")
+ }
+}
+
+// A successful save must close the dialog even when the slots do not resolve.
+// The write DID succeed; leaving the modal open makes the user click Save
+// again and again. This regressed once: the !slots branch returned early.
+func TestUIKeyAutoSaveClosesOnSuccess(t *testing.T) {
+ src := uiSource(t)
+ body := readFuncBody(t, src, "keyAutoSave")
+ // keyAutoClose() must be reached on every success path, not only in the
+ // happy branch.
+ iClose := strings.Index(body, "keyAutoClose()")
+ if iClose < 0 {
+ t.Fatal("keyAutoSave never calls keyAutoClose — the dialog would stay open")
+ }
+ iToast := strings.Index(body, "toast(")
+ if iToast >= 0 && iClose < iToast {
+ t.Fatal("keyAutoClose is called before the toast; the warning must be " +
+ "shown first or it is lost with the modal")
+ }
+ // An early `return` inside a branch bypasses keyAutoClose entirely, so
+ // merely finding the call is not enough: every `return` that sits between
+ // entering the try block and the close call is a path that leaves the
+ // dialog open after a successful write.
+ tryStart := strings.Index(body, "try {")
+ seg := body[tryStart:iClose]
+ for _, line := range strings.Split(seg, "\n") {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "return;" || strings.HasPrefix(trimmed, "return ") {
+ t.Fatal("a `return` before keyAutoClose leaves the dialog open after " +
+ "a successful save: " + trimmed)
+ }
+ }
+}
+
+// readFuncBody returns the source of a top-level function by name, using
+// brace matching so nested blocks do not truncate it.
+func readFuncBody(t *testing.T, src, name string) string {
+ t.Helper()
+ idx := strings.Index(src, "function "+name+"(")
+ if idx < 0 {
+ t.Fatalf("function %s not found", name)
+ }
+ start := strings.Index(src[idx:], "{")
+ if start < 0 {
+ t.Fatalf("function %s has no body", name)
+ }
+ i := idx + start
+ depth := 0
+ for j := i; j < len(src); j++ {
+ switch src[j] {
+ case '{':
+ depth++
+ case '}':
+ depth--
+ if depth == 0 {
+ return src[i : j+1]
+ }
+ }
+ }
+ t.Fatalf("function %s body is unterminated", name)
+ return ""
+}