agent: the 接入 switch and a model picked in the row, both (owner: 对于那种想在 magpie 中一键设置 Agent 模型的用户能否也兼容一下,鱼和熊掌都要兼得); a row opens in place and slides as on iOS (owner: 展开的时候,上/下 padding 变了,导致抖动;展开和折叠都没有 iOS 那种丝滑优雅精致的动画)

Every agent row keeps its 接入 switch and now has its model picker beside it. Picking a magpie model on an agent that isn't connected connects it first (agent.Pick, used by /api/set), so one pick sets the agent up; disconnecting still puts back what the agent had. The intro line says both ways.

An opened row shook: the connected row centred its lines in its 56px min-height, and the 3px row-gap couldn't be folded away, so opening moved the name and the switch and changed the space under them. The first line now has its own height and isn't centred, the row-gap is 0 with the spacing in margins, and the opened part starts where the shut row ended (56/56 at 980px, 90/90 at 560px; the name at 8px and the switch at 19px open and shut). Opening and closing animate the part's height, padding, margin and border on iOS's sheet curve cubic-bezier(.32,.72,0,1), 460 ms open and 340 ms shut, and what's in it fades in from 6px above; a row already open slides shut before another opens. With reduced motion it opens at once.

Gemini CLI's .env made by magpie for its key was left holding a newline after a disconnect (main too): DelEnvFile now removes a dotenv file once the deleted keys were all it held.

TestPickConnectsFirst, the only.env case in TestEnvFile, agent-start-pick and agent-expand-steady (name and switch unmoved, the opened part's top at the shut row's end, at least 3 heights between on the way open and shut, growing/shrinking only, chromium and webkit at 980 and 560px) fail on the old code and pass. Tests that open rows wait for the slide. In a sandbox HOME with real accounts: Claude Code and Gemini CLI picked while not connected answer PONG and disconnect back to what they had; Goose's pick → disconnect round trip is byte-identical (goose itself stops at a Linear MCP OAuth, so no request). go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui for edit, agent and gui under a temp HOME pass; GUI tests that fail here fail the same on 82340f41 (account-arrange, cli-update, mobile-web, provider-levels-scroll, provider-proxy, redact-rule-row, routing-compact, provider-detect-models, plugin-move).
This commit is contained in:
yetone committed 2026-10-04 02:05:10 +08:00
1 parent f3f055001b
commit df27f0882a
22 files changed
+431 -62

No files matched your search

+18
View File
@@ -293,6 +293,24 @@ func (a *Agent) Wired() bool {
return false
}
// Pick sets a field from the Agents page's model picker. One of magpie's
// models picked on an agent not connected yet connects it first, as its
// switch does, then starts it on that model (the owner: the switch, and a
// model picked in magpie in one click, both): the agent gets magpie's whole
// list, and Disconnect puts back what it had before.
func (a *Agent) Pick(key, v string) error {
if f := a.Field(key); f != nil && !a.Wired() {
vals := a.Values()
vals[key] = v
if v == magpieID || magpieValue(a, *f, v, vals) {
if err := a.Connect(); err != nil {
return err
}
}
}
return a.Apply(key, v)
}
// Connect puts magpie into the agent's config, the Agents page's switch
// (the owner: an agent is connected to magpie, and its models are picked in
// the agent): the agent's main field goes to one of magpie's models — the
+39
View File
@@ -274,3 +274,42 @@ func TestClaudeDisconnectKeepsTheUsersEffort(t *testing.T) {
t.Fatalf("connected again on %v:\n%s", v, readFile(path))
}
}
// A model of magpie's picked on the Agents page for an agent not connected
// connects it first, as its switch does (the owner: the switch, and one
// click in magpie, both): Goose starts on the model picked, and
// disconnecting it brings back its own provider and model.
func TestPickConnectsFirst(t *testing.T) {
home := syncHome(t)
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))
if err := provider.Save(provider.Provider{ID: "oa", Name: "OA", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"gpt-5.5", "gpt-5.4"}}); err != nil {
t.Fatal(err)
}
a := goose(home, filepath.Join(home, ".config"))
writeFile(t, a.Path, "GOOSE_PROVIDER: anthropic\nGOOSE_MODEL: claude-opus-5-5\n")
var key, pick string
for _, f := range a.Fields {
for _, o := range f.Options(a.Values()) {
if o.Ref == "oa/gpt-5.4" {
key, pick = f.Key, o.Value
}
}
}
if pick == "" {
t.Fatal("no oa/gpt-5.4 to pick")
}
if err := a.Pick(key, pick); err != nil {
t.Fatal(err)
}
if !a.Wired() || a.Values()[key] != pick {
t.Fatalf("picked %q: %v\n%s", pick, a.Values(), readFile(a.Path))
}
if err := a.Disconnect(); err != nil {
t.Fatal(err)
}
for k, want := range map[string]string{"GOOSE_PROVIDER": "anthropic", "GOOSE_MODEL": "claude-opus-5-5"} {
if v, _ := edit.GetYAMLTop(a.Path, k); v != want {
t.Fatalf("%s = %q after disconnecting:\n%s", k, v, readFile(a.Path))
}
}
}
+12 -1
View File
@@ -1,6 +1,9 @@
package edit
import "testing"
import (
"os"
"testing"
)
func TestDelJSONMiddleKeepsLayout(t *testing.T) {
in := "{\n \"env\": {\n \"A\": \"1\",\n \"B\": \"2\",\n \"C\": \"3\"\n },\n \"model\": \"m\"\n}\n"
@@ -72,6 +75,14 @@ func TestEnvFile(t *testing.T) {
if got := read(t, p); got != "# comment\nexport BAZ=\"q x\"\n" {
t.Fatalf("got %q", got)
}
// the keys all it held: the file goes, not a blank line left
only := tmpFile(t, "only.env", "GEMINI_API_KEY=magpie\nGOOGLE_GEMINI_BASE_URL=http://127.0.0.1:3425\n")
if err := DelEnvFile(only, "GEMINI_API_KEY", "GOOGLE_GEMINI_BASE_URL"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(only); !os.IsNotExist(err) {
t.Fatalf("left %q", read(t, only))
}
np := tmpFile(t, "new.env", "")
if err := SetEnvFile(np, KV{"A", "1"}); err != nil {
t.Fatal(err)
+5 -1
View File
@@ -58,7 +58,8 @@ func SetEnvFile(path string, kvs ...KV) error {
return WriteAtomic(path, []byte(joinLines(lines)))
}
// DelEnvFile removes keys from a dotenv file.
// DelEnvFile removes keys from a dotenv file, and the file when they were
// all it held (Gemini CLI's .env, made by magpie for its key).
func DelEnvFile(path string, keys ...string) error {
raw, err := Read(path)
if err != nil || len(raw) == 0 {
@@ -80,5 +81,8 @@ func DelEnvFile(path string, keys ...string) error {
if !changed {
return nil
}
if strings.TrimSpace(strings.Join(out, "")) == "" {
return Remove(path)
}
return WriteAtomic(path, []byte(joinLines(out)))
}
+1 -1
View File
@@ -608,7 +608,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
http.Error(rw, "unknown field", http.StatusBadRequest)
return
}
if err := a.Apply(f.Key, strings.TrimSpace(in.Value)); err != nil {
if err := a.Pick(f.Key, strings.TrimSpace(in.Value)); err != nil {
fail(rw, err)
return
}
+20 -1
View File
@@ -2655,7 +2655,12 @@ body.window .row.agent > .ag-conn { margin-left: 12px; align-self: center; curso
.ag-lead .ag-lead-h { display: block; font-size: 14px; margin-bottom: 4px; }
.ag-lead p { margin: 0; line-height: 1.55; color: var(--muted); }
.ag-lead .text.primary { margin-top: 12px; }
body.window .row.agent.conn { flex-wrap: wrap; min-height: 56px; }
/* the first line keeps its place opened: a line of its own height, the
row not centring what it holds; no gap between its lines, which the
opened part couldn't fold away: it starts where the shut row ended */
body.window .row.agent.conn { flex-wrap: wrap; min-height: 56px; align-content: flex-start; row-gap: 0; }
body.window .row.agent.conn > .who { min-height: 42px; align-content: center; }
body.window .row + .row.agent.conn > .who { min-height: 41px; } /* the line above it is in the 56px */
.ag-st { flex: 0 0 100%; min-width: 0; display: flex; align-items: center; gap: 6px; font-size: 12px; line-height: 18px; color: var(--muted); }
.ag-st .ag-st-t { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ag-st .ag-st-t b { font-weight: 600; color: var(--fg-2); }
@@ -2680,6 +2685,16 @@ body.window .row.agent.conn { flex-wrap: wrap; min-height: 56px; }
.row.agent.conn > .ag-link + .ag-conn { margin-left: 4px; }
.row.agent.conn > .who + .ag-conn, .row.agent.conn > .who + .ag-add, .row.agent.conn > .who + .ag-cant { margin-left: auto; }
.row.agent.conn > .ag-conn:disabled { opacity: .4; cursor: default; }
/* the model it starts on, beside the switch: one width down the list, so
the pickers and the switches line up */
.row.agent.conn > .field.ag-start { flex: none; width: 196px; margin-left: 8px; align-self: center; justify-content: flex-start; }
.row.agent.conn > .who + .field.ag-start { margin-left: auto; }
.row.agent.conn > .field.ag-start .v { flex: 1; text-align: left; }
.row.agent.conn > .field.ag-start .chev { margin-left: auto; }
.row.agent.conn > .field.ag-start + .ag-conn { margin-left: 10px; }
.row.agent.conn > .field.launch { flex: none; margin-left: 8px; align-self: center; }
.row.agent.conn > .who + .field.launch { margin-left: auto; }
.row.agent.conn > .field.launch + .field.ag-start { margin-left: 0; }
.ag-add { flex: none; height: 24px; padding: 0 10px; border: 0; border-radius: 6px; background: var(--pill); color: var(--fg); font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; }
.ag-add:hover { background: var(--pill-hover); }
.ag-cant { flex: none; font-size: 12px; color: var(--faint); }
@@ -2742,4 +2757,8 @@ body.window .row.agent.conn { flex-wrap: wrap; min-height: 56px; }
.ag-vl > .fields > .field.side { flex: 1 0 124px; }
.ag-vl > .fields > .extras-cell { flex-wrap: wrap; max-width: 100%; }
.ag-vl .field.ag-pick { flex: 1 1 160px; width: auto; min-width: 0; }
/* short of room, the model it starts on takes a line of its own under
the name, the link and the switch staying beside it */
.row.agent.conn > .field.ag-start, .row.agent.conn > .who + .field.ag-start { order: 1; flex: 1 1 100%; width: auto; margin: 9px 0 0 34px; }
.row.agent.conn > .ag-exp { order: 2; }
}
+65 -17
View File
@@ -897,10 +897,7 @@ function connectLine(a, kind) {
}
const at = PICKS_IN[a.id];
if (a.id === "claude") say(t("Connected · switch the opus · sonnet · haiku tiers in /model"));
else if (NO_PICKER.has(a.id)) {
const f = connectField(a), o = f && optionFor(f, f.value);
say(o ? t("Connected · starts on {model}", { model: o.label || f.value }) : t("Connected · pick the model it starts on"));
} else if (at && a.models) say(t("Connected · {n} models in {agent}'s {cmd}", { n: a.models.shown, agent: a.name, cmd: at }));
else if (NO_PICKER.has(a.id)) say(t("Connected · starts on the model picked here")); else if (at && a.models) say(t("Connected · {n} models in {agent}'s {cmd}", { n: a.models.shown, agent: a.name, cmd: at }));
else say(connectSaid(a));
return line;
}
@@ -919,12 +916,61 @@ function expandLink(a) {
}
b.onclick = (e) => {
e.stopPropagation();
const shut = document.querySelector("#agents .row.agent.expanded > .ag-exp");
agentExpanded = open ? null : a.id;
renderAgents();
const want = agentExpanded;
// the one open slides shut first, then the list is drawn again
sheet(shut, false, () => {
if (agentExpanded !== want) return;
renderAgents();
if (want) sheet(document.querySelector(`#agents .row.agent[data-id="${CSS.escape(want)}"] > .ag-exp`), true);
});
};
return b;
}
// sheet slides a row's opened part open or shut, as a sheet moves on iOS:
// its height on iOS's own curve, what is in it fading in a little below
function sheet(box, open, done) {
if (!box || !box.animate || matchMedia("(prefers-reduced-motion: reduce)").matches) { done?.(); return; }
const cs = getComputedStyle(box);
const full = { height: box.offsetHeight + "px", paddingTop: cs.paddingTop, paddingBottom: cs.paddingBottom, marginTop: cs.marginTop, marginBottom: cs.marginBottom, borderTopWidth: cs.borderTopWidth, opacity: 1 };
const none = { height: "0px", paddingTop: "0px", paddingBottom: "0px", marginTop: "0px", marginBottom: "0px", borderTopWidth: "0px", opacity: 0 };
const ease = "cubic-bezier(.32, .72, 0, 1)";
box.style.overflow = "hidden";
const run = box.animate(open ? [none, full] : [full, none], { duration: open ? 460 : 340, easing: ease, fill: open ? "none" : "forwards" });
for (const c of box.children) c.animate(open ? [{ opacity: 0, transform: "translateY(-6px)" }, { opacity: 1, transform: "none" }] : [{ opacity: 1 }, { opacity: 0 }], { duration: open ? 420 : 200, delay: open ? 60 : 0, easing: open ? ease : "ease-out", fill: "backwards" });
run.onfinish = () => { if (open) box.style.overflow = ""; done?.(); };
run.oncancel = run.onfinish;
}
// startField: the field an agent's model is picked in, magpie's among
// its options (Gemini CLI's model, not how it signs in)
function startField(a) {
return a.fields.find((f) => f.key === "model" && f.options.some((o) => o.ref)) || connectField(a);
}
// startButton picks the model an agent starts on, from the row. Not
// connected, it lists magpie's models alone, and the one picked connects
// the agent and starts it on it; connected, every choice there is.
function startButton(a, f, fieldBtn) {
const b = fieldBtn(f, "ag-start");
if (!a.wired) {
b.replaceChildren(el("span", "v empty", t("Pick a model")));
const c = el("span", "chev");
c.append(svg(CHEV, 11, 1.7));
b.append(c);
b.title = t("Pick one of magpie's models: {agent} is connected and starts on it", { agent: a.name });
b.onclick = (ev) => openPicker(a, f, b, ev, (o) => !!o.ref);
} else if (!f.value && a.id !== "claude") {
b.querySelector(".v").textContent = t("Its last pick");
b.title = t("Unset, {agent} starts on its own last pick", { agent: a.name });
}
// Codex's is the very value its /model picks: one choice
if (a.wired && a.id === "codex") b.title += "\n" + t("The same choice as {agent}'s {cmd}: change it here or there", { agent: a.name, cmd: PICKS_IN.codex });
return b;
}
// connectRow finishes a window row that has 「接入」
function connectRow(a, row, who, { fields, extras, fieldBtn, sw, kind }) {
// its list open, what was picked there until the state has it
@@ -933,10 +979,17 @@ function connectRow(a, row, who, { fields, extras, fieldBtn, sw, kind }) {
row.classList.toggle("unplugged", kind === "ok" && !a.wired);
who.classList.add("with-models");
who.append(connectLine(a, kind));
const noPicker = NO_PICKER.has(a.id);
// the model it starts on, one click away beside the switch (the owner:
// both ways at once): picked here, it is connected first if it wasn't
const start = kind === "ok" ? startField(a) : null;
if (start) fields.querySelector(`:scope > [data-key="${CSS.escape(start.key)}"]`)?.remove();
if (kind === "ok") {
if (noPicker && a.wired) row.append(fields);
if (a.wired) row.append(expandLink(a));
// the square copying the command that starts it on magpie (agy), the
// only way it takes magpie: on the row, before the model
const launch = a.wired && fields.querySelector(".field.launch");
if (launch) row.append(launch);
if (start) row.append(startButton(a, start, fieldBtn));
row.append(sw);
} else if (kind === "empty") {
const add = el("button", "ag-add", t("Add a provider"));
@@ -954,7 +1007,7 @@ function connectRow(a, row, who, { fields, extras, fieldBtn, sw, kind }) {
} else row.append(el("span", "ag-cant", t("Can't connect")));
if (kind === "ok" && a.wired && agentExpanded === a.id) {
row.classList.add("expanded");
row.append(connectPanel(a, { fields: noPicker ? null : fields, extras, fieldBtn }));
row.append(connectPanel(a, { fields: fields.querySelector(".field") ? fields : null, extras, fieldBtn }));
}
}
@@ -975,12 +1028,12 @@ function agentsLead(list) {
const first = !state.agents.some((a) => a.wired);
lead.classList.toggle("welcome", first);
if (!first) {
const [pre, post] = t("Switch an agent on and the models you set up in magpie show up as the {magpie} provider in its own model list.").split("{magpie}");
const [pre, post] = t("Switch an agent on and the models you set up in magpie show up as the {magpie} provider in its own model list; or pick its model right here.").split("{magpie}");
lead.append(pre, el("b", "", "magpie"), post || "");
return;
}
lead.append(el("b", "ag-lead-h", t("Let your agents use the models you set up in magpie")));
lead.append(el("p", "", t("Switch an agent on and your models show up as the magpie provider in its own model list. Then change models in the agent; switch it off and it goes back to how it was.")));
lead.append(el("p", "", t("Switch an agent on and your models show up as the magpie provider in its own model list, to change in the agent. Or pick a model right here: the agent is connected and starts on it. Switch it off and it goes back to how it was.")));
if (!anyMagpieModels()) {
const add = el("button", "text primary", t("Add a provider"));
add.type = "button";
@@ -1097,13 +1150,8 @@ function connectPanel(a, { fields, fieldBtn }) {
}
// what a new session starts on: optional, the agent's own last pick
// unset; Codex's is the very value its /model picks, so one choice
if (fields && a.id === "codex") {
kv(t("New sessions"), line(fields), el("div", "ag-hint", t("The same choice as {agent}'s {cmd}: change it here or there", { agent: a.name, cmd: at })));
} else if (fields && a.id !== "claude") {
kv(t("New sessions"), line(fields), el("div", "ag-hint", t("Optional · unset, {agent} starts on its own last pick", { agent: a.name })));
} else if (fields && a.id === "claude") {
kv(t("New sessions"), line(fields));
}
// the model it starts on is on the row; what goes with it here
if (fields) kv(t("New sessions"), line(fields));
if (CONNECT_COST[a.id]) kv(t("Once connected"), line(t(CONNECT_COST[a.id])));
if (a.launch) {
const cp = el("button", "ag-quiet", t("Copy"));
+7 -5
View File
@@ -155,8 +155,6 @@ const I18N = {
"Connected · {n} models · {new} show in new {agent} sessions": "已接入 · {n} 个模型 · {new}在新的 {agent} 会话里可见",
"{n} new": "新加的 {n} 个",
"Connected · switch the opus · sonnet · haiku tiers in /model": "已接入 · 在 /model 里切换 opus · sonnet · haiku 档位",
"Connected · starts on {model}": "已接入 · 用 {model} 启动",
"Connected · pick the model it starts on": "已接入 · 选一个启动模型",
"Connected · {n} models in {agent}'s {cmd}": "已接入 · {agent} 的 {cmd} 里有 {n} 个模型",
"Collapse": "收起",
"Tiers": "档位",
@@ -180,7 +178,6 @@ const I18N = {
"Pick": "挑选",
"Model list": "模型列表",
"New sessions": "新会话默认",
"Optional · unset, {agent} starts on its own last pick": "可选 · 不设就用 {agent} 自己上次的选择",
"The same choice as {agent}'s {cmd}: change it here or there": "和 {agent} 的 {cmd} 是同一个选择,在哪边改都行",
"Reopen {agent}: its own models stay in {cmd}, and magpie's join them.": "重开 {agent} 后,{cmd} 里自己的模型照旧,magpie 的模型也在里面。",
"Once connected": "接入后",
@@ -198,9 +195,14 @@ const I18N = {
"Reading what changes…": "正在读取要改回的地方…",
"magpie's provider table stays, so sessions opened on magpie's models still open": "保留 magpie 供应商表,用 magpie 模型开过的会话还能打开",
"Running {agent} copies take it once reopened": "正在运行的 {agent} 重开后生效",
"Switch an agent on and the models you set up in magpie show up as the {magpie} provider in its own model list.": "打开「接入」,你在 magpie 配的模型会以 {magpie} 供应商出现在 agent 自己的模型列表里。",
"Switch an agent on and the models you set up in magpie show up as the {magpie} provider in its own model list; or pick its model right here.": "打开「接入」,你在 magpie 配的模型会以 {magpie} 供应商出现在 agent 自己的模型列表里;也可以直接在这里选模型。",
"Pick a model": "选模型",
"Pick one of magpie's models: {agent} is connected and starts on it": "选一个 magpie 的模型:{agent} 会自动接入并用它启动",
"Its last pick": "上次的选择",
"Unset, {agent} starts on its own last pick": "不设就用 {agent} 自己上次的选择",
"Connected · starts on the model picked here": "已接入 · 用这里选的模型启动",
"Let your agents use the models you set up in magpie": "让 agent 用上你在 magpie 里配的模型",
"Switch an agent on and your models show up as the magpie provider in its own model list. Then change models in the agent; switch it off and it goes back to how it was.": "打开一个 agent 的「接入」,你的模型就会以 magpie 供应商出现在它自己的模型列表里。之后在 agent 里换模型就行;关掉开关,它会回到原来的样子。",
"Switch an agent on and your models show up as the magpie provider in its own model list, to change in the agent. Or pick a model right here: the agent is connected and starts on it. Switch it off and it goes back to how it was.": "打开一个 agent 的「接入」,你的模型就会以 magpie 供应商出现在它自己的模型列表里,在 agent 里随时换。也可以直接在这里选模型:agent 会自动接入并用它启动。关掉开关,它会回到原来的样子。",
"Connected · pick magpie's models with {cmd} in {agent}": "已接入 · 在 {agent} 里用 {cmd} 选 magpie 的模型",
"Connected · magpie's models are in {agent}'s model list": "已接入 · magpie 的模型在 {agent} 的模型列表里",
"Connect {agent} to magpie": "将 {agent} 接入 magpie",
+5 -5
View File
@@ -111,12 +111,12 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await ask.waitFor({ state: "detached" });
assert.deepEqual(posts, []);
// the model picker, in the opened row, has it too, beside Default;
// Claude Code, not connected, has no picker to open
assert.equal(await page.locator(`${row("claude")} .field`).count(), 0);
// the model picker beside the switch has it too, beside Default;
// Claude Code, not connected, has only magpie's models to pick there
// and no row to open
assert.equal(await page.locator(`${row("claude")} .field:not(.ag-start)`).count(), 0);
assert.equal(await page.locator(`${row("claude")} .ag-link`).count(), 0);
await page.locator(`${row("codex")} .ag-link`).click();
await page.locator(`${row("codex")} .field[data-key="model"]`).click();
await page.locator(`${row("codex")} > .field.ag-start[data-key="model"]`).click();
const pickItem = page.locator("#list li", { hasText: w.item });
await pickItem.waitFor();
assert.ok(await pickItem.getAttribute("title"), "it says what it does");
@@ -0,0 +1,107 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// The owner: opening a connected agent's row shook it — the line with its
// name moved and the space under it grew — and opening and closing jumped
// instead of moving as iOS does. Opened, the name and the switch stay where
// they were and the opened part starts right where the shut row ended; it
// slides open and shut through the heights between, and no step at either
// end. With reduced motion it opens at once. Wide and narrow windows; no
// backend, the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const model = (id, label) => ({ key: "model", label: "model", value: id, options: [{ value: id, label, ref: "relay/" + id, note: "Relay · via magpie" }] });
const state = {
agents: [
{ id: "codex", name: "Codex", icon: "generic", path: "/fixture/codex", wired: true, fields: [model("m1", "m1"), { key: "effort", label: "effort", value: "high", options: [{ value: "high", label: "High" }] }] },
{ id: "goose", name: "Goose", icon: "generic", path: "/fixture/goose", wired: true, fields: [model("m2", "m2")] },
],
profiles: [],
};
async function serve(route) {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"en",theme:"light",web:false};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ ...state, settings: { lang: "en", theme: "light" } });
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/agents/cli") return json({ agents: {}, pending: false });
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await route.fulfill({ body: await fs.readFile(file), contentType });
}
const row = '.row.agent[data-id="codex"]';
// where the row's line sits: its height shut and its bottom line, the
// name's and the switch's offsets, and the opened part's top
const where = (page) => page.evaluate((r) => {
const el = document.querySelector(r), top = el.getBoundingClientRect().top;
const at = (s) => Math.round(el.querySelector(s)?.getBoundingClientRect().top - top);
return { height: Math.round(el.getBoundingClientRect().height), line: parseFloat(getComputedStyle(el).borderBottomWidth), who: at(":scope > .who"), sw: at(":scope > .ag-conn"), exp: el.querySelector(".ag-exp") ? at(":scope > .ag-exp") : null };
}, row);
// the row's height every frame for a while, from a click on its link
const heights = (page, ms) => page.evaluate(([r, ms]) => new Promise((done) => {
const hs = [], t0 = performance.now();
const f = () => { hs.push(Math.round(document.querySelector(r).getBoundingClientRect().height)); if (performance.now() - t0 < ms) requestAnimationFrame(f); else done(hs); };
document.querySelector(r + " .ag-link").click();
requestAnimationFrame(f);
}), [row, ms]);
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const width of [980, 560]) {
test(`${engine} ${width}px: a row opens in place, and slides`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const page = await browser.newPage({ viewport: { width, height: 700 } });
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve);
await page.goto("http://magpie.test/?view=agents");
await page.locator(`${row} .ag-conn`).waitFor();
const shut = await where(page);
const opening = await heights(page, 800);
const open = await where(page);
assert.equal(open.who, shut.who, "the name moved");
assert.equal(open.sw, shut.sw, "the switch moved");
// its top line where the shut row's bottom line was
assert.equal(open.exp, shut.height - shut.line, "the opened part starts where the shut row ended");
const full = open.height;
assert.ok(full > shut.height + 60, `opened: ${full}`);
// the heights between, growing, no step bigger than a frame's worth
const mid = opening.filter((h) => h > shut.height && h < full);
assert.ok(mid.length >= 3, "slid open: " + opening);
for (let i = 1; i < opening.length; i++) assert.ok(opening[i] >= opening[i - 1], "grew only: " + opening);
const closing = await heights(page, 700);
assert.ok(closing.filter((h) => h > shut.height && h < full).length >= 3, "slid shut: " + closing);
for (let i = 1; i < closing.length; i++) assert.ok(closing[i] <= closing[i - 1], "shrank only: " + closing);
assert.deepEqual(await where(page), shut, "shut again as it was");
// another row open: the one open slides shut, this one opens
await page.locator(`${row} .ag-link`).click();
await page.waitForTimeout(700);
await page.locator('.row.agent[data-id="goose"] .ag-link').click();
await page.waitForTimeout(1000);
assert.equal(await page.locator(".row.agent .ag-exp").count(), 1);
assert.equal(await page.locator('.row.agent[data-id="goose"] .ag-exp').count(), 1);
assert.equal((await where(page)).height, shut.height);
// reduced motion: open at once
await page.emulateMedia({ reducedMotion: "reduce" });
await page.locator('.row.agent[data-id="goose"] .ag-link').click();
await page.waitForTimeout(500);
const now = await heights(page, 120);
assert.equal(now[0], full, "opened at once: " + now);
assert.deepEqual(errors, []);
});
}
}
+11 -3
View File
@@ -1,6 +1,7 @@
// An agent's name must stay readable when the main window is narrow or
// enlarged, without making its 「接入」 switch and the link that opens it run
// past the row; the switches line up down the list.
// enlarged, without making its 「接入」 switch, the link that opens it and
// the model it starts on run past the row; the switches and the model
// pickers line up down the list.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
@@ -68,7 +69,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.locator('.row.agent[data-id="claude"] .ag-conn').waitFor();
const boxes = () => page.locator(".row.agent").evaluateAll((els) => els.map((el) => {
const box = (e) => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width }; };
return { row: box(el), who: box(el.querySelector(".who")), controls: [...el.querySelectorAll(":scope > .ag-link, :scope > .ag-conn")].map(box) };
return { row: box(el), who: box(el.querySelector(".who")), controls: [...el.querySelectorAll(":scope > .ag-link, :scope > .ag-conn")].map(box), start: box(el.querySelector(":scope > .ag-start")) };
}));
for (const width of [520, 560, 600, 660, 601, 960]) {
await page.setViewportSize({ width, height: 700 });
@@ -83,6 +84,13 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
assert(ok, `controls stay clear of the name at ${width}px: ${JSON.stringify({ who, c })}`);
}
}
// the model it starts on, one click away: inside the row, the same
// width down the list, beside the switch or on a line under the name
for (const { row, who, start } of rows) {
assert(start.width >= 120 && start.right <= row.right - 8, `the model picker fits at ${width}px: ${JSON.stringify({ row, start })}`);
assert(width > 600 ? start.x >= who.right : start.y >= who.bottom - 1, `the model picker stays clear of the name at ${width}px: ${JSON.stringify({ who, start })}`);
}
assert(Math.abs(rows[0].start.right - rows[1].start.right) < 1 && Math.abs(rows[0].start.width - rows[1].start.width) < 1, `the model pickers line up at ${width}px`);
const rights = rows.map((r) => r.controls[1].right);
assert(Math.abs(rights[0] - rights[1]) < 1, `the switches line up at ${width}px: ${JSON.stringify(rights)}`);
if (width === 520) narrowShot = await page.screenshot();
+2
View File
@@ -93,6 +93,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const hidden = async () => (await row.locator(".ag-exp .ag-chips .ag-hint").allInnerTexts()).join("").trim();
const chips = async () => (await row.locator(".ag-chip").allInnerTexts()).map((s) => s.replace(/\s+/g, " "));
await entry.waitFor();
// the row has slid open (its slide is agent-expand-steady's)
await page.waitForFunction(() => document.getAnimations().length === 0);
assert.equal(await hidden(), w.entry);
assert.deepEqual(await chips(), ["Routing groups 1", "OpenAI 6", "OpenRouter 1"].map((c) => lang === "zh" ? c.replace("Routing groups", "路由组") : c));
// the line under the name counts them too
@@ -0,0 +1,112 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// The owner: the 「接入」 switch, and the model picked in magpie in one click,
// both. Beside an agent's switch is the model it starts on. Not connected,
// it reads Pick a model and lists magpie's models alone; the one picked is
// posted to set, which connects the agent first, and the row is connected,
// on that model. Connected, its picker has every choice: the agent's own
// models and its default too. No click moves the page. In English and
// Chinese; no backend, the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const options = [{ value: "gpt-5.4", label: "GPT-5.4" }, { value: "relay/m1", label: "m1", ref: "relay/m1", note: "Relay · via magpie" }, { value: "relay/m2", label: "m2", ref: "relay/m2", note: "Relay · via magpie" }];
const fresh = () => ({
agents: [{ id: "codex", name: "Codex", icon: "generic", path: "/fixture/codex", fields: [{ key: "model", label: "model", value: "gpt-5.4", options }] }],
profiles: [],
});
function server(lang, posts) {
let cur = fresh();
const reply = (route) => route.fulfill({ json: { ...cur, settings: { lang, theme: "light" } } });
return async (route) => {
const req = route.request(), url = new URL(req.url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return reply(route);
if (url.pathname === "/api/set") {
const body = JSON.parse(req.postData());
posts.push(body);
cur = JSON.parse(JSON.stringify(cur));
const a = cur.agents.find((x) => x.id === body.agent);
a.fields[0].value = body.value;
if (body.value.startsWith("relay/")) a.wired = true;
return reply(route);
}
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/agents/cli") return json({ agents: {}, pending: false });
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await route.fulfill({ body: await fs.readFile(file), contentType });
};
}
const words = {
en: { pick: "Pick a model", on: "Connected · pick magpie's models with /model in Codex" },
zh: { pick: "选模型", on: "已接入 · 在 Codex 里用 /model 选 magpie 的模型" },
};
const row = '.row.agent[data-id="codex"]';
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
const w = words[lang];
test(`${engine} ${lang}: a model picked beside the switch connects the agent on it`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const page = await (await browser.newContext({ viewport: { width: 980, height: 520 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [], posts = [], dialogs = [];
page.on("pageerror", (e) => errors.push(e.message));
page.on("dialog", (d) => { dialogs.push(d.message()); d.dismiss(); });
await page.route("**/*", server(lang, posts));
await page.goto("http://magpie.test/?view=agents");
await page.locator(`${row} .ag-conn`).waitFor();
const missing = await page.evaluate(() => [
"Pick a model",
"Pick one of magpie's models: {agent} is connected and starts on it",
"Its last pick",
"Unset, {agent} starts on its own last pick",
"Connected · starts on the model picked here",
"Switch an agent on and the models you set up in magpie show up as the {magpie} provider in its own model list; or pick its model right here.",
].filter((k) => !I18N.zh[k]));
assert.deepEqual(missing, [], "every string has its Chinese");
const tops = () => page.evaluate(() => [document.scrollingElement.scrollTop, document.querySelector("#view-agents")?.scrollTop]);
const top = await tops();
const start = page.locator(`${row} > .field.ag-start`);
const listed = async () => {
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
return page.locator("#pop #list li").allTextContents();
};
// not connected: magpie's models alone
assert.equal((await start.textContent()).trim(), w.pick);
assert.equal(await page.locator(`${row} .ag-conn`).getAttribute("aria-checked"), "false");
await start.click();
const off = await listed();
assert.ok(off.some((s) => s.includes("m1")) && off.some((s) => s.includes("m2")), off.join(" | "));
assert.ok(!off.some((s) => s.includes("GPT-5.4")), "the agent's own models aren't offered: " + off.join(" | "));
await page.locator("#pop #list li", { hasText: "m2" }).first().click();
await page.waitForFunction((r) => document.querySelector(r + " .ag-conn")?.getAttribute("aria-checked") === "true", row);
assert.deepEqual(posts, [{ agent: "codex", field: "model", value: "relay/m2" }]);
assert.equal(await page.locator(`${row} .ag-st`).textContent(), w.on);
assert.match(await start.textContent(), /m2/);
// connected: every choice
await start.click();
const on = await listed();
assert.ok(on.some((s) => s.includes("GPT-5.4")) && on.some((s) => s.includes("m1")), on.join(" | "));
await page.keyboard.press("Escape");
assert.deepEqual(await tops(), top, "no click moved the page");
assert.deepEqual(dialogs, [], "no native dialog");
assert.deepEqual(errors, []);
});
}
}
+6 -5
View File
@@ -1,8 +1,8 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Antigravity CLI (agy) takes magpie's gateway only from its environment, so
// its row on the Agents page has a square that copies the command starting
// it on magpie, once it is connected (its model picker stays in the row, as
// agy picks no model once started): the command in its tooltip, one click copying it (posted to /api/copy) and saying so, with the
// it on magpie, once it is connected (right before the model picker beside
// its switch, agy picking no model once started): the command in its tooltip, one click copying it (posted to /api/copy) and saying so, with the
// page left where it was; none on an agent without one; in the tray panel's
// opened row too; the words in Chinese. No backend: the API is faked here.
const assert = require("node:assert/strict");
@@ -71,9 +71,10 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const title = await b.getAttribute("title");
assert.match(title, /Antigravity CLI takes magpie only from its environment/);
assert(title.includes(LAUNCH), title);
// in the squares' column, the model picker beside it on its line
const [sq, pick] = await page.evaluate((r) => [".field.launch", ".field:not(.extra)"].map((s) => document.querySelector(r + " " + s).getBoundingClientRect()), row("agy"));
assert(sq.left > pick.right - 1, "the square comes after the picker");
// on the row, right before the model it starts on, so the pickers
// and the switches line up down the list
const [sq, pick] = await page.evaluate((r) => [".field.launch", ".field.ag-start"].map((s) => document.querySelector(r + " " + s).getBoundingClientRect()), row("agy"));
assert(sq.right <= pick.left + 1 && pick.left - sq.right <= 12, "the square comes right before the picker");
assert(Math.abs((sq.top + sq.height / 2) - (pick.top + pick.height / 2)) <= 2, "the square is on the picker's line");
// scrolled a little, so a scroll would show
const view = page.locator("#view-agents");
+4 -4
View File
@@ -7,7 +7,7 @@
// a model Claude Code asks Anthropic for itself says so: the row's tooltip
// says it isn't through magpie and why the file names no magpie endpoint,
// and picking one says "straight to Anthropic". A magpie model says neither.
// The model is picked in the connected row, opened from its link. No click
// The model is picked beside the connected row's switch. No click
// moves the page. In English and Chinese, Chromium and WebKit.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
@@ -86,8 +86,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const sets = [];
await page.route("**/*", server(lang, sets));
await page.goto("http://magpie.test/");
await page.locator(`${row} .ag-link`).click();
const field = page.locator(`${row} .ag-exp .field[data-key="model"]`);
// beside the switch, one click away
const field = page.locator(`${row} > .field.ag-start[data-key="model"]`);
await field.waitFor();
// the alias, as the model it stands for, with Claude's logo
@@ -99,7 +99,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
// the page scrolled, a pick moves nothing and says where it goes
const view = page.locator("#view-agents");
await page.mouse.move(400, 300);
// down to the opened row's picker, so the click has no need to scroll
// down to the row's picker, so the click has no need to scroll
for (let i = 0; i < 8; i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(20); }
await page.waitForTimeout(300);
const top = await view.evaluate((v) => v.scrollTop);
@@ -13,7 +13,7 @@
// Esc and the arrows did nothing); a query shows the rows it finds, so an id
// typed in full is picked with Enter (which only opened the fold); and a
// star on a dated id still shows (its row was gone, Favorites empty). The
// picker is the connected row's, opened from its link.
// picker is the one beside the connected row's switch.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
@@ -90,8 +90,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.route("**/*", server(lang, value, sets));
await page.goto("http://magpie.test/");
await page.locator(row).waitFor();
await page.locator(`${row} .ag-link`).click();
await page.locator(`${row} .ag-exp .field[data-key="model"]`).click();
await page.locator(`${row} > .field.ag-start[data-key="model"]`).click();
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
await page.waitForTimeout(400); // the picker grows open
return page;
+2 -3
View File
@@ -54,9 +54,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.waitForTimeout(700);
await page.locator(`${row} .ag-open .field[data-key="model"]`).click();
} else {
// the window: in the connected agent's opened row
await page.locator(`${row} .ag-link`).click();
await page.locator(`${row} .ag-exp .field[data-key="model"]`).click();
// the window: beside the connected agent's switch
await page.locator(`${row} > .field.ag-start[data-key="model"]`).click();
}
const li = page.locator("#pop:not([hidden]) #list li").filter({ hasText: "Label claude-opus-5-5" }).first();
await li.waitFor();
+4 -5
View File
@@ -73,12 +73,11 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.locator(row).waitFor();
return page;
};
// the window: the connected agent's row opened, and scrolled down to its
// picker as a reader would (the page puts back any scroll that isn't
// the window: scrolled down to the connected agent's picker, in its row,
// as a reader would (the page puts back any scroll that isn't
// the reader's), by at least `least` turns of the wheel
const toPicker = async (page, field, least = 0) => {
const view = page.locator("#view-agents");
await page.locator(`${row} .ag-link`).click();
await page.mouse.move(400, 300);
const shown = async () => { const f = await field.boundingBox(), v = await view.boundingBox(); return f.y + f.height + 8 <= v.y + v.height; };
for (let i = 0; i < least || !(await shown()); i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(i < least ? 20 : 80); }
@@ -96,7 +95,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const sets = [];
const page = await open("http://magpie.test/", lang, sets);
const view = page.locator("#view-agents");
const field = page.locator(`${row} .ag-exp .field[data-key="model"]`);
const field = page.locator(`${row} > .field.ag-start[data-key="model"]`);
await toPicker(page, field, 2);
const top = await view.evaluate((v) => v.scrollTop);
assert(top > 0, "the list must be scrolled");
@@ -136,7 +135,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await t.test("a refused pick puts the old model back", async () => {
const sets = [];
const page = await open("http://magpie.test/", "en", sets, { refuse: true });
const field = page.locator(`${row} .ag-exp .field[data-key="model"]`);
const field = page.locator(`${row} > .field.ag-start[data-key="model"]`);
await toPicker(page, field);
await field.click();
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
+1 -2
View File
@@ -129,8 +129,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
// an agent's model picker
await page.goto("http://magpie.test/");
await page.locator('.row.agent[data-id="claude"] .ag-link').click();
await page.locator('.row.agent[data-id="claude"] .ag-exp .field[data-key="model"]').click();
await page.locator('.row.agent[data-id="claude"] > .field.ag-start[data-key="model"]').click();
const list = page.locator("#pop:not([hidden]) #list");
await list.locator("li").filter({ hasText: "Qwen3.8-Max" }).first().waitFor();
const rows = await list.locator("li[data-i]").evaluateAll((ls) => ls.map((l) => {
+2 -1
View File
@@ -136,7 +136,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
for (const id of ids) {
// Hermes picks no model once started: its pickers stay in the row
const more = page.locator(`${row(id)} .ag-link`);
if (await more.count()) { await more.click(); await page.waitForTimeout(100); }
// the one open slides shut before this one opens
if (await more.count()) { await more.click(); await page.locator(`${row(id)} .ag-exp`).waitFor(); }
icons.push(await lit(page, id));
}
const panel = await load("en", [], "http://magpie.test/?mode=panel");
+4 -2
View File
@@ -103,11 +103,13 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
assert.equal(await page.locator('.row.agent[data-id="cc-magpie"] .field.extra[data-key="subagent"]').getAttribute("aria-label"), w.follows);
await expand(codex);
// a square beside the subagents' one; model and effort alone are pickers
// a square beside the subagents' one; model (in the row, connected) and
// effort alone are pickers
const square = page.locator(`${codex} .ag-exp .extras-cell .field.extra[data-key="subagent_effort"]`);
assert.equal(await square.count(), 1);
assert.equal(await page.locator(`${codex} .extras-cell .field.extra[data-key="subagent"]`).count(), 1);
assert.deepEqual(await page.locator(`${codex} .ag-exp .field:not(.extra)`).evaluateAll((es) => es.map((e) => e.dataset.key)), ["model", "effort"]);
assert.equal(await page.locator(`${codex} > .field.ag-start[data-key="model"]`).count(), 1);
assert.deepEqual(await page.locator(`${codex} .ag-exp .field:not(.extra)`).evaluateAll((es) => es.map((e) => e.dataset.key)), ["effort"]);
assert.equal(await square.getAttribute("aria-label"), w.unset);
assert.equal(await square.evaluate((e) => e.classList.contains("set")), false);
+2 -3
View File
@@ -101,9 +101,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
// the window's picker: hy3 is reached through Mine, picked at a click
let posts = [];
let page = await open("http://magpie.test/", posts);
// connected: its picker is in its opened row
await page.locator(`${row} .ag-link`).click();
const field = page.locator(`${row} .ag-exp .field[data-key="model"]`);
// connected: its picker is in its row
const field = page.locator(`${row} > .field.ag-start[data-key="model"]`);
await field.waitFor();
const view = page.locator("#view-agents");
await filterFor(page, field, "hy3");