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");