From df27f0882a1c6a8cc514b16bf217123c10996f94 Mon Sep 17 00:00:00 2001 From: yetone Date: Sun, 4 Oct 2026 01:14:12 +0800 Subject: [PATCH] =?UTF-8?q?agent:=20the=20=E6=8E=A5=E5=85=A5=20switch=20an?= =?UTF-8?q?d=20a=20model=20picked=20in=20the=20row,=20both=20(owner:=20?= =?UTF-8?q?=E5=AF=B9=E4=BA=8E=E9=82=A3=E7=A7=8D=E6=83=B3=E5=9C=A8=20magpie?= =?UTF-8?q?=20=E4=B8=AD=E4=B8=80=E9=94=AE=E8=AE=BE=E7=BD=AE=20Agent=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=9A=84=E7=94=A8=E6=88=B7=E8=83=BD=E5=90=A6?= =?UTF-8?q?=E4=B9=9F=E5=85=BC=E5=AE=B9=E4=B8=80=E4=B8=8B=EF=BC=8C=E9=B1=BC?= =?UTF-8?q?=E5=92=8C=E7=86=8A=E6=8E=8C=E9=83=BD=E8=A6=81=E5=85=BC=E5=BE=97?= =?UTF-8?q?);=20a=20row=20opens=20in=20place=20and=20slides=20as=20on=20iO?= =?UTF-8?q?S=20(owner:=20=E5=B1=95=E5=BC=80=E7=9A=84=E6=97=B6=E5=80=99?= =?UTF-8?q?=EF=BC=8C=E4=B8=8A/=E4=B8=8B=20padding=20=E5=8F=98=E4=BA=86?= =?UTF-8?q?=EF=BC=8C=E5=AF=BC=E8=87=B4=E6=8A=96=E5=8A=A8=EF=BC=9B=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E5=92=8C=E6=8A=98=E5=8F=A0=E9=83=BD=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=20iOS=20=E9=82=A3=E7=A7=8D=E4=B8=9D=E6=BB=91=E4=BC=98=E9=9B=85?= =?UTF-8?q?=E7=B2=BE=E8=87=B4=E7=9A=84=E5=8A=A8=E7=94=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- internal/agent/applied.go | 18 +++ internal/agent/connect_pick_test.go | 39 ++++++ internal/edit/del_test.go | 13 +- internal/edit/env.go | 6 +- internal/gui/api.go | 2 +- internal/gui/assets/app.css | 21 +++- internal/gui/assets/app.js | 82 ++++++++++--- internal/gui/assets/i18n.js | 12 +- internal/gui/tests/agent-disconnect.test.cjs | 10 +- .../gui/tests/agent-expand-steady.test.cjs | 107 +++++++++++++++++ internal/gui/tests/agent-layout.test.cjs | 14 ++- internal/gui/tests/agent-models.test.cjs | 2 + internal/gui/tests/agent-start-pick.test.cjs | 112 ++++++++++++++++++ internal/gui/tests/agy-launch.test.cjs | 11 +- internal/gui/tests/claude-direct.test.cjs | 8 +- .../gui/tests/claude-picker-fold.test.cjs | 5 +- internal/gui/tests/favorite-star.test.cjs | 5 +- internal/gui/tests/model-pick.test.cjs | 9 +- internal/gui/tests/model-rate.test.cjs | 3 +- internal/gui/tests/panel-effort.test.cjs | 3 +- internal/gui/tests/subagent-effort.test.cjs | 6 +- internal/gui/tests/unlisted-models.test.cjs | 5 +- 22 files changed, 431 insertions(+), 62 deletions(-) create mode 100644 internal/gui/tests/agent-expand-steady.test.cjs create mode 100644 internal/gui/tests/agent-start-pick.test.cjs diff --git a/internal/agent/applied.go b/internal/agent/applied.go index 8614ae60..78f0ded9 100644 --- a/internal/agent/applied.go +++ b/internal/agent/applied.go @@ -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 diff --git a/internal/agent/connect_pick_test.go b/internal/agent/connect_pick_test.go index da2a1502..c3bf44b0 100644 --- a/internal/agent/connect_pick_test.go +++ b/internal/agent/connect_pick_test.go @@ -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)) + } + } +} diff --git a/internal/edit/del_test.go b/internal/edit/del_test.go index 16baedf6..a02278f0 100644 --- a/internal/edit/del_test.go +++ b/internal/edit/del_test.go @@ -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) diff --git a/internal/edit/env.go b/internal/edit/env.go index 87a336c6..479e2c63 100644 --- a/internal/edit/env.go +++ b/internal/edit/env.go @@ -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))) } diff --git a/internal/gui/api.go b/internal/gui/api.go index 3f2e41ca..43e91708 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -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 } diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 6ee57746..dcb6651c 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -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; } } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 1d4e0cc4..73e1b3a6 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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")); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index ecfdf483..261fca12 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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", diff --git a/internal/gui/tests/agent-disconnect.test.cjs b/internal/gui/tests/agent-disconnect.test.cjs index 3161bcfc..784b5f0e 100644 --- a/internal/gui/tests/agent-disconnect.test.cjs +++ b/internal/gui/tests/agent-disconnect.test.cjs @@ -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"); diff --git a/internal/gui/tests/agent-expand-steady.test.cjs b/internal/gui/tests/agent-expand-steady.test.cjs new file mode 100644 index 00000000..0b1f0449 --- /dev/null +++ b/internal/gui/tests/agent-expand-steady.test.cjs @@ -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, []); + }); + } +} diff --git a/internal/gui/tests/agent-layout.test.cjs b/internal/gui/tests/agent-layout.test.cjs index 811cf62d..683b40d1 100644 --- a/internal/gui/tests/agent-layout.test.cjs +++ b/internal/gui/tests/agent-layout.test.cjs @@ -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(); diff --git a/internal/gui/tests/agent-models.test.cjs b/internal/gui/tests/agent-models.test.cjs index 2e4ceb56..a83dfd8c 100644 --- a/internal/gui/tests/agent-models.test.cjs +++ b/internal/gui/tests/agent-models.test.cjs @@ -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 diff --git a/internal/gui/tests/agent-start-pick.test.cjs b/internal/gui/tests/agent-start-pick.test.cjs new file mode 100644 index 00000000..8a394538 --- /dev/null +++ b/internal/gui/tests/agent-start-pick.test.cjs @@ -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, []); + }); + } +} diff --git a/internal/gui/tests/agy-launch.test.cjs b/internal/gui/tests/agy-launch.test.cjs index 2f0e9ca9..5302b07a 100644 --- a/internal/gui/tests/agy-launch.test.cjs +++ b/internal/gui/tests/agy-launch.test.cjs @@ -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"); diff --git a/internal/gui/tests/claude-direct.test.cjs b/internal/gui/tests/claude-direct.test.cjs index adb8e5a3..79ef5637 100644 --- a/internal/gui/tests/claude-direct.test.cjs +++ b/internal/gui/tests/claude-direct.test.cjs @@ -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); diff --git a/internal/gui/tests/claude-picker-fold.test.cjs b/internal/gui/tests/claude-picker-fold.test.cjs index 08a7a2d0..9d81ae6f 100644 --- a/internal/gui/tests/claude-picker-fold.test.cjs +++ b/internal/gui/tests/claude-picker-fold.test.cjs @@ -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; diff --git a/internal/gui/tests/favorite-star.test.cjs b/internal/gui/tests/favorite-star.test.cjs index 6cf3d32d..341fdc94 100644 --- a/internal/gui/tests/favorite-star.test.cjs +++ b/internal/gui/tests/favorite-star.test.cjs @@ -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(); diff --git a/internal/gui/tests/model-pick.test.cjs b/internal/gui/tests/model-pick.test.cjs index b06667e2..e37ed9db 100644 --- a/internal/gui/tests/model-pick.test.cjs +++ b/internal/gui/tests/model-pick.test.cjs @@ -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(); diff --git a/internal/gui/tests/model-rate.test.cjs b/internal/gui/tests/model-rate.test.cjs index a24482bf..eba171b4 100644 --- a/internal/gui/tests/model-rate.test.cjs +++ b/internal/gui/tests/model-rate.test.cjs @@ -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) => { diff --git a/internal/gui/tests/panel-effort.test.cjs b/internal/gui/tests/panel-effort.test.cjs index 5618e606..af1d902b 100644 --- a/internal/gui/tests/panel-effort.test.cjs +++ b/internal/gui/tests/panel-effort.test.cjs @@ -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"); diff --git a/internal/gui/tests/subagent-effort.test.cjs b/internal/gui/tests/subagent-effort.test.cjs index bdbd30ac..45bf7d63 100644 --- a/internal/gui/tests/subagent-effort.test.cjs +++ b/internal/gui/tests/subagent-effort.test.cjs @@ -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); diff --git a/internal/gui/tests/unlisted-models.test.cjs b/internal/gui/tests/unlisted-models.test.cjs index 0a418d1d..b383b881 100644 --- a/internal/gui/tests/unlisted-models.test.cjs +++ b/internal/gui/tests/unlisted-models.test.cjs @@ -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");