diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html
index bff34e1..9bd187d 100644
--- a/internal/gateway/ui/index.html
+++ b/internal/gateway/ui/index.html
@@ -3710,7 +3710,7 @@ recsState.keyNames = keyNames || {};
const k = document.getElementById("key-auto-canvas");
if (k) return k;
}
- return document.getElementById("scr-canvas");
+return document.getElementById("scr-canvas");
}
function paintSort(affected) {
const cv = sortCanvasEl();
@@ -4133,6 +4133,25 @@ const cv = sortCanvasEl();
// editor means a per-key chain gets exactly the features the global one
// has (drag ordering, per-slot quota/period/hours) instead of a reduced
// second implementation that would drift.
+ // afterChainEdit is the single place an in-canvas edit decides whether to
+ // write through. On the GLOBAL page edits take effect immediately (that
+ // page has always behaved that way). In the per-key dialog they must NOT:
+ // that dialog has Save and Cancel, and auto-persisting meant a slot added
+ // (or dragged, or deleted) was already committed to the user's chain
+ // before Save — Cancel could not undo it, and the "Click Save for it to
+ // take effect" hint was a lie.
+function afterChainEdit() {
+ // Every in-canvas edit lands here, so this is the one place that keeps
+ // the "uses the global chain" notice honest while the user works.
+ if (typeof keyAutoInheritNotice === "function") keyAutoInheritNotice();
+ if (sortState.scope) {
+ toast(t("sortHintSave"));
+ return;
+ }
+ persistAuto()
+ .then(() => toast(t("sortHintSave")))
+ .catch((e) => toast(e.message));
+ }
async function persistAuto() {
const rules = lanesToRules(sortState.lanes);
if (sortState.scope) {
@@ -4248,9 +4267,7 @@ const cv = sortCanvasEl();
if (btn) btn.closest("#modal-wrap")?.remove();
else closeTopModal();
paintSort([li]);
- persistAuto()
- .then(() => toast(t("sortHintSave")))
- .catch((e) => toast(e.message));
+ afterChainEdit();
}
function sortScopeSave(li, ji, btn) {
const it = sortState.lanes[li].models[ji];
@@ -4263,9 +4280,7 @@ const cv = sortCanvasEl();
it.meta = { quota: q, period: p, hours: p || q ? h : 0 };
(btn ? btn.closest("#modal-wrap") : null) || closeTopModal();
paintSort([li]);
- persistAuto()
- .then(() => toast(t("sortHintSave")))
- .catch((e) => toast(e.message));
+ afterChainEdit();
}
function scrCtx(e, li, ji) {
if (e.button === 2) {
@@ -4328,9 +4343,7 @@ const cv = sortCanvasEl();
meta: it.meta ? { ...it.meta } : null,
});
paintSort([li]);
- persistAuto()
- .then(() => toast(t("sortHintSave")))
- .catch((e) => toast(e.message));
+ afterChainEdit();
},
},
];
@@ -4341,9 +4354,7 @@ const cv = sortCanvasEl();
fn: () => {
it.meta = null;
paintSort([li]);
- persistAuto()
- .then(() => toast(t("sortHintSave")))
- .catch((e) => toast(e.message));
+ afterChainEdit();
},
});
items.push({
@@ -4359,9 +4370,7 @@ const cv = sortCanvasEl();
if (!sortState.lanes[li].models.length && sortState.lanes.length > 1)
sortState.lanes.splice(li, 1);
paintSort([li]);
- persistAuto()
- .then(() => toast(t("sortHintSave")))
- .catch((e) => toast(e.message));
+ afterChainEdit();
}
/* ---------- adapters tab ---------- */
@@ -5213,8 +5222,16 @@ const cv = sortCanvasEl();
// /api/keys/{key}/auto instead of /api/auto. A separate reduced editor
// would drift from the global one and lose features.
async function openKeyAutoModal(key, name) {
+ // This dialog deliberately does NOT reuse #modal-wrap. That id is
+ // shared by every dialog in the app and more than one can be open at
+ // once, so a lookup by id resolves to whichever comes first in the
+ // document — not the one the user is looking at. That is fine for
+ // handlers that can use `.closest()` from their own button, but this
+ // dialog also has to be found from the CANVAS side (sortCanvasEl,
+ // keyAutoClose, keyAutoInherit), so it needs an identity of its own.
+ document.getElementById("key-auto-modal")?.remove();
const wrap = document.createElement("div");
- wrap.id = "modal-wrap";
+ wrap.id = "key-auto-modal";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
wrap.innerHTML = `
@@ -5236,25 +5253,45 @@ const cv = sortCanvasEl();
const cur = await api("/api/keys/" + encodeURIComponent(key) + "/auto");
keyAutoState.key = key;
keyAutoState.inherits = !!cur.inherits;
- // A key with no own chain shows an empty canvas rather than the
- // discovery fallback: filling it with every available slot would
- // suggest those slots are already this user's chain.
+ // A key with no own chain shows an EMPTY canvas, never the discovery
+ // fallback buildLanes uses for an unconfigured global chain: that
+ // fallback fills every source's models (226 blocks on a real
+ // deployment), and showing them inside "this key's chain" dialog
+ // says they already belong to this user. They do not.
sortState.scope = key;
sortState.kind = "chat";
- sortState.lanes = buildLanes(idx, cur.auto || [], "chat");
+ sortState.lanes =
+ cur.auto && cur.auto.length ? buildLanes(idx, cur.auto, "chat") : [];
sortState.origin = JSON.stringify(sortState.lanes);
await loadModelPairs();
renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" });
+ // The notice is the only thing telling the user what an empty
+ // canvas means here. Without it the dialog is just a blank box and
+ // "Use global chain" looks like a button that already did
+ // something. It disappears as soon as the chain has slots of its
+ // own, which keyAutoSave's reload then confirms.
if (keyAutoState.inherits) keyAutoInheritNotice();
} catch (e) {
toast(String(e));
}
}
const keyAutoState = { key: null, inherits: true };
- function keyAutoInheritNotice() {
+ // The notice describes what an EMPTY canvas means, so it must track the
+ // canvas rather than the state the dialog opened with: adding the first
+ // slot makes "this key uses the global chain" false, and leaving it up
+ // reads as a contradiction right next to the slot the user just added.
+ function keyAutoInheritNotice(showing) {
const host = $("#ka-canvas-host");
if (!host) return;
let n = host.querySelector(".ka-inherit-note");
+ const hasSlots = (sortState.lanes || []).some(
+ (l) => (l.models || []).length,
+ );
+ const want = showing === undefined ? !hasSlots : showing && !hasSlots;
+ if (!want) {
+ if (n) n.remove();
+ return;
+ }
if (!n) {
n = document.createElement("div");
n.className = "muted ka-inherit-note";
@@ -5278,7 +5315,7 @@ const cv = sortCanvasEl();
keyAutoInheritNotice();
}
function keyAutoClose() {
- const m = document.getElementById("modal-wrap");
+ const m = document.getElementById("key-auto-modal");
if (m) m.remove();
// Leaving scope set would make the global editor save to this key.
sortState.scope = null;
diff --git a/internal/gateway/ui_key_auto_test.go b/internal/gateway/ui_key_auto_test.go
index a9a4206..ba72a15 100644
--- a/internal/gateway/ui_key_auto_test.go
+++ b/internal/gateway/ui_key_auto_test.go
@@ -204,3 +204,65 @@ func readFuncBody(t *testing.T, src, name string) string {
t.Fatalf("function %s body is unterminated", name)
return ""
}
+
+// The per-key dialog must not share the app-wide #modal-wrap id.
+//
+// Every dialog in this file uses that id, and more than one can be open at
+// once (the "add slot" picker opens ON TOP of this one). Handlers resolve
+// their own dialog via `.closest()` from their button, which works — but this
+// dialog is also looked up from the CANVAS side (sortCanvasEl, keyAutoClose,
+// keyAutoInherit), where there is no button to walk up from.
+// getElementById("#modal-wrap") then returns whichever dialog comes first in
+// the document, i.e. not the one being edited: Cancel closed the wrong box and
+// the canvas could resolve to the picker's.
+func TestUIKeyAutoModalHasOwnID(t *testing.T) {
+ src := uiSource(t)
+ body := readFuncBody(t, src, "openKeyAutoModal")
+ if !strings.Contains(body, `wrap.id = "key-auto-modal"`) {
+ t.Fatal("openKeyAutoModal must give its dialog its own id, not the shared " +
+ "#modal-wrap (another dialog may be open on top of it)")
+ }
+ close := readFuncBody(t, src, "keyAutoClose")
+ if strings.Contains(close, `"modal-wrap"`) {
+ t.Fatal("keyAutoClose resolves the shared #modal-wrap; it would close " +
+ "whichever dialog comes first in the document")
+ }
+ if !strings.Contains(close, `"key-auto-modal"`) {
+ t.Fatal("keyAutoClose must resolve the per-key dialog by its own id")
+ }
+}
+
+// Editing a chain inside the per-key dialog must NOT persist it. That dialog
+// has Save and Cancel, so auto-writing on add/drag/delete meant the change was
+// already committed before Save and Cancel could not undo it.
+//
+// The global page is the opposite: it has always applied edits immediately.
+func TestUIKeyAutoEditsDoNotAutoPersist(t *testing.T) {
+ src := uiSource(t)
+ body := readFuncBody(t, src, "afterChainEdit")
+ if !strings.Contains(body, "if (sortState.scope)") {
+ t.Fatal("afterChainEdit must branch on sortState.scope")
+ }
+ // The scoped branch must return before reaching persistAuto.
+ iScope := strings.Index(body, "if (sortState.scope)")
+ iReturn := strings.Index(body[iScope:], "return")
+ iPersist := strings.Index(body, "persistAuto()")
+ if iScope < 0 || iReturn < 0 || iPersist < 0 {
+ t.Fatal("afterChainEdit no longer has the expected shape")
+ }
+ if iScope+iReturn > iPersist {
+ t.Fatal("the scoped branch reaches persistAuto() — a per-key edit would " +
+ "be written before Save")
+ }
+ // And it must not call itself (a regex-driven rewrite did exactly that).
+ if strings.Contains(body, "afterChainEdit()") {
+ t.Fatal("afterChainEdit calls itself: infinite recursion")
+ }
+ // Every in-canvas edit goes through it, so none of them bypass the check.
+ for _, fn := range []string{"scrAddFromForm", "sortScopeSave", "scrDelSlot"} {
+ b := readFuncBody(t, src, fn)
+ if strings.Contains(b, "persistAuto()") {
+ t.Fatalf("%s calls persistAuto() directly, bypassing afterChainEdit", fn)
+ }
+ }
+}