diff --git a/docs/subsystems/gui-shell.md b/docs/subsystems/gui-shell.md index eef0d708..d62080a5 100644 --- a/docs/subsystems/gui-shell.md +++ b/docs/subsystems/gui-shell.md @@ -34,6 +34,16 @@ three. - The page must work in Chromium (Windows' WebView2) and WebKit (macOS, and WebKitGTK on Linux). GUI tests run in both engines. - Tests never touch a live agent config. The package's `TestMain` runs under `testenv`'s home of its own. Playwright tests serve `assets/` with isolated `/api` fixtures. +`openModal` makes the background inert when an editor opens fresh, leaving +confirmations and menus interactive through redraws. `closeModal` releases +that background as soon as the close animation starts, so clicks reach the +page while the editor fades away. Opening an editor during that animation +protects the background again. `ux-safety.test.cjs` checks these transitions. +Routing's New group buttons share `newGroup`, which asks before replacing a +dirty group draft; Cancel retains the draft and Discard opens the new editor. +`newGroupWith` applies the same guard when creating a group from a model and +waits for `show` to accept navigation before creating its draft. + ## Verification ```sh diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 9b783d08..9df9b893 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -162,6 +162,9 @@ body.window.mac .top { padding-left: calc(96px / var(--zoom, 1)); height: calc(5 body.window.mac .seg { top: calc(50% + 1px / var(--zoom, 1)); } /* 50% centres in the padding box; the 2px top padding puts the flex row a pixel lower */ .brand { display: flex; align-items: center; gap: 6px; font-weight: 600; letter-spacing: .01em; } body.window:not(.mac):not(.linux):not(.web) .brand { display: none; } /* Windows' title bar above already shows the icon and the name; a browser tab has no title bar of the app's */ +/* app.js removes these controls later; keep them out of the first paint too. */ +body.window :is(#open, #openMain, #quit), +body:not(.window.linux) #winclose { display: none; } /* Linux: the header is the title bar, so it closes the window too — round, as GNOME draws its close button, and set a little apart from the icons */ .icon.winclose { align-self: center; width: 22px; height: 22px; margin-left: 8px; border-radius: 50%; background: var(--pill); color: var(--fg-2); } @@ -1845,7 +1848,6 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .accts .keys-tools .grow { flex: 1; } .accts .keys-tools .keys-filter { flex: 1 1 220px; max-width: 320px; height: 26px; padding: 0 8px; font-size: 12px; } .accts .keys-tools .keys-count { white-space: nowrap; font-variant-numeric: tabular-nums; } -.accts .keys-tools .keys-remove[data-armed] { color: var(--red); background: var(--red-soft); } .accts .keys-tools + .acc { border-top: 0; } .accts > .keys-tools:first-child { border-top: 0; } .accts > .keys-none + .acc, .accts > .keys-rest + .acc { border-top: 1px solid var(--line-2); } @@ -2545,6 +2547,17 @@ html.theming, html.theming *, html.theming *::before, html.theming *::after { transition: background-color .35s var(--ease), background .35s var(--ease), color .35s var(--ease), border-color .35s var(--ease), box-shadow .35s var(--ease) !important; } +/* Confirmation uses the top layer so an open editor remains intact underneath. */ +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } +.action-confirm { --confirm-fill: #c73535; --confirm-fg: #ffffff; position: fixed; inset: 0; margin: auto; max-height: calc(100vh - 32px); overflow-y: auto; width: min(420px, calc(100vw - 32px)); padding: 22px; border: 1px solid var(--line); border-radius: 14px; background: var(--card); color: var(--fg); box-shadow: 0 24px 60px rgba(0, 0, 0, .32); font: inherit; } +.action-confirm .text.danger-fill { background: var(--confirm-fill); color: var(--confirm-fg); } +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .action-confirm { --confirm-fill: var(--red); --confirm-fg: #14142a; } } +:root[data-theme="dark"] .action-confirm { --confirm-fill: var(--red); --confirm-fg: #14142a; } +.action-confirm::backdrop { background: rgba(0, 0, 0, .32); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); } +.action-confirm h2 { margin: 0 0 12px; font-size: 16px; overflow-wrap: anywhere; } +.action-confirm p { margin: 0 0 20px; color: var(--fg-2); line-height: 1.6; white-space: pre-line; overflow-wrap: anywhere; } +.action-confirm .bar { display: flex; align-items: center; gap: 10px; margin: 0; } +@media (pointer: coarse) { .action-confirm button { min-height: 44px; min-width: 44px; } } /* ---------- modal ---------- */ .modal { position: fixed; inset: 0; z-index: 20; diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 2c6b7d07..17cfa996 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -3,32 +3,18 @@ const $ = (s) => document.querySelector(s); const $$ = (s) => document.querySelectorAll(s); const params = new URLSearchParams(location.search); const mode = params.get("mode") || "window"; -document.body.classList.add(mode); // The panel loads Wails for ExecJS readiness, but stays attached to the tray. if (mode === "panel") $("header.top").style.setProperty("--wails-draggable", "no-drag"); // `magpie web`: the page in a browser tab, with no window of the app's // around it — it opens links itself, and what is the desktop's is left out const web = !!window.bootPrefs?.web; -if (web) document.body.classList.add("web"); +// layout.js applies the mode and platform before the header can paint. // Gateway mode (gatewaymode.go): magpie web on a server that is only the // gateway for other computers' agents leaves out this computer's agents' // pages — Agents, Sessions, Library — and their settings; Settings › // General turns it off, and they are back. let gatewayMode = web && !!window.bootPrefs?.gateway; const GATEWAY_HIDES = ["agents", "sessions", "library"]; -// iOS zooms the page into a field it focuses whose text is under 16px, and -// leaves it zoomed; at most 1 stops that, and Safari still lets a pinch zoom -if (web && (/iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1))) { - document.querySelector('meta[name="viewport"]')?.setAttribute("content", "width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"); -} -// The Mac window draws its title bar inside the page (the traffic lights); -// on Linux the page's header is the whole title bar (plainTitlebar), so it -// has the name, the close button and a double-click to maximise. -if (!web && /^Mac/.test(navigator.platform)) document.body.classList.add("mac"); -if (!web && /^Linux/.test(navigator.platform)) document.body.classList.add("linux"); -// Windows: its own UI faces by name, Chinese in Microsoft YaHei UI rather -// than whatever the webview falls back to for it -if (/^Win/.test(navigator.platform)) document.documentElement.classList.add("win"); // The window is dragged by its header, and only where the header says so // (--wails-draggable), so the tabs and buttons in it stay plain clicks. // Outside the app — a browser on the gateway's page, or on `magpie web` — @@ -36,14 +22,8 @@ if (/^Win/.test(navigator.platform)) document.documentElement.classList.add("win // console, Jorben on Discord). // The panel also needs runtime readiness to drain Go's queued ExecJS calls. const winRuntime = !web ? import("/wails/runtime.js").catch(() => null) : Promise.resolve(null); -if (params.get("theme")) document.documentElement.dataset.theme = params.get("theme"); -// the saved language and theme from boot.js, so the first paint is in them -if (window.bootPrefs) { - const b = window.bootPrefs; - if (!params.get("theme") && b.theme && b.theme !== "system") document.documentElement.dataset.theme = b.theme; - setLocale(b.lang); - document.documentElement.style.setProperty("--zoom", b.web ? 1 : (b.textSize || 100) / 100); -} +// theme.js already applied the theme and text size before the first paint. +if (window.bootPrefs) setLocale(window.bootPrefs.lang); let state = { agents: [], profiles: [], catalog: "", settings: {} }; let prefs = null; // the settings page: theme, lang, version, dir, gateway @@ -211,6 +191,7 @@ function status(msg, kind = "", ms = kind === "err" ? 8000 : 3500) { s.textContent = msg; s.title = msg; s.className = "status " + kind; + s.setAttribute("role", kind === "err" ? "alert" : "status"); // with a dialog open the footer is under its scrim: the pill floats over both const m = $("#modal"); s.classList.toggle("lift", !!msg && !m.hidden && !m.classList.contains("out")); @@ -1187,8 +1168,8 @@ function agentsLead(list) { } } -function addProviderFromAgents() { - show("providers"); +async function addProviderFromAgents() { + if (!await show("providers")) return; $("#addProvider")?.click(); } @@ -4475,7 +4456,10 @@ function renderProviders() { // a provider on is listed, and tried, in the order the user puts it in // (#499): its logo is the handle, as an agent row's is row.append(p.off ? icon(p.icon || "generic") : providerHandle(p, row), who, uses, key, providerSwitch(p), chev); - row.onclick = () => { editing = open ? null : p.id; draft = null; renderProviders(); }; // the preset sheet stays as it is under the dialog + row.onclick = async () => { + if (providerDirty() && !(await confirmDiscard())) return; + editing = open ? null : p.id; draft = null; renderProviders(); + }; // the preset sheet stays as it is under the dialog list.append(row); if (open) dialog = renderEditor(p); } @@ -4717,7 +4701,7 @@ function askForgetSaved(x) { go.classList.add("busy"); for (const [i, user] of x.users.entries()) { const last = i === x.users.length - 1; - if (!await accountAction("login/forget", { agent: x.agent, user }, last ? t("{user} removed", { user: x.users.join(", ") }) : "")) { + if (!await accountAction("login/forget", { agent: x.agent, user }, last ? t("{user} removed", { user: x.users.join(", ") }) : "", true)) { go.disabled = false; go.classList.remove("busy"); return; @@ -4784,7 +4768,7 @@ function askSignOutLogin(a, l) { e.stopPropagation(); go.disabled = true; go.classList.add("busy"); - if (await accountAction("login/forget", { agent: a.agent, user: l.user }, t("{agent} signed out of {user}", { agent: a.agentName, user: l.user }))) closeConfirmAsk(); + if (await accountAction("login/forget", { agent: a.agent, user: l.user }, t("{agent} signed out of {user}", { agent: a.agentName, user: l.user }), true)) closeConfirmAsk(); else go.disabled = false; }; const cancel = el("button", "text", t("Cancel")); @@ -5211,7 +5195,16 @@ function segs(items, current, onPick) { let key = kind + "#" + ++segsMade; for (const [id, name] of items) { const b = el("button", "opt" + (id === current ? " on" : ""), name); - b.onclick = () => { for (const x of box.querySelectorAll(".opt")) x.classList.toggle("on", x === b); slide(box, key); onPick(id); }; + b.type = "button"; + b.setAttribute("aria-pressed", String(id === current)); + b.onclick = () => { + for (const x of box.querySelectorAll(".opt")) { + x.classList.toggle("on", x === b); + x.setAttribute("aria-pressed", String(x === b)); + } + slide(box, key); + onPick(id); + }; box.append(b); } queueMicrotask(() => { // once it is in the page @@ -6168,26 +6161,27 @@ function morePluginsTile(q) { } // openPlugins: the Plugins tab, looking for q when there is one -function openPlugins(q) { +async function openPlugins(q) { if (mode !== "window") { api("window/main?view=plugins", {}).catch(() => {}); return; } - show("plugins"); + if (!await show("plugins")) return; window.pluginQuery?.(q || ""); } // pluginSignIn: a plugin's provider signed in to as every subscription is, // in the Providers add sheet -function pluginSignIn(id) { +async function pluginSignIn(id) { + if (!await show("providers")) return; closeModal(); - show("providers"); adding = true; editing = null; draft = null; presetQuery = ""; renderProviders(); startSignIn(id); } // openProvider: a provider opened in the Providers tab -function openProvider(id) { +async function openProvider(id) { + if (view === "providers" && providerDirty() && !(await confirmDiscard())) return; + if (!await show("providers")) return; closeModal(); - show("providers"); adding = false; editing = id; draft = null; presetQuery = ""; renderProviders(); syncURL(); @@ -6270,11 +6264,26 @@ function tile(pr) { return b; } +let fieldIDs = 0; function field(label, control, hint) { const l = el("label", "", label); const wrap = el("div"); wrap.append(control); - if (hint) wrap.append(el("div", "hint", hint)); + const inputSelector = "input, select, textarea"; + const inputs = control.matches(inputSelector) ? [control] : [...control.querySelectorAll(inputSelector)]; + if (label && inputs.length) { + for (const input of inputs) { + if (!input.id) input.id = "field-" + ++fieldIDs; + if (!input.closest("label") && !input.hasAttribute("aria-label") && !input.hasAttribute("aria-labelledby")) input.setAttribute("aria-label", label); + } + l.htmlFor = inputs[0].id; + } + if (hint) { + const help = el("div", "hint", hint); + help.id = "field-help-" + ++fieldIDs; + wrap.append(help); + for (const input of inputs) input.setAttribute("aria-describedby", [input.getAttribute("aria-describedby"), help.id].filter(Boolean).join(" ")); + } return [l, wrap]; } // decimalOf is a number as typed in a price box, a comma taken for the @@ -6311,15 +6320,91 @@ function input(value, placeholder, type = "text") { i.placeholder = placeholder || ""; i.spellcheck = false; i.autocomplete = "off"; - i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Escape") cancelEdit(); }; + i.onkeydown = (e) => { + e.stopPropagation(); + if (e.key === "Escape") { + if (editing !== null || importing || importingApps) cancelEdit(); + } + }; return i; } // keep: leaving the page, a sign-in under way in the editor goes on; the // editor put away (Cancel, Escape, a click outside) takes it with it (#526) -function cancelEdit(keep) { +let providerDraftRef = null, providerDraftBase = ""; +function providerDraftValue() { + return JSON.stringify({ ...draft, headers: headersOf(draft?.headers), modelPrefs: modelPrefsOfDraft() }); +} +function providerDirty() { + // Price boxes parse on change; a page closed while typing hasn't blurred one yet. + const active = document.activeElement; + return !!draft && draft === providerDraftRef && (providerDraftValue() !== providerDraftBase || + (active?.matches(".editor .mprice input") && active.value !== active.defaultValue)); +} +let confirmationPending = null; +function confirmAction(title, message, action) { + if (confirmationPending) return Promise.resolve(false); + const dialog = el("dialog", "action-confirm"); + dialog.setAttribute("role", "alertdialog"); + dialog.setAttribute("aria-labelledby", "actionConfirmTitle"); + dialog.setAttribute("aria-describedby", "actionConfirmMessage"); + const heading = el("h2", "", title); heading.id = "actionConfirmTitle"; + const description = el("p", "", message); description.id = "actionConfirmMessage"; + const bar = el("div", "bar"); + const cancel = el("button", "text", t("Cancel")); + const accept = el("button", "text primary danger-fill", action); + cancel.type = accept.type = "button"; + bar.append(el("span", "grow"), cancel, accept); + dialog.append(heading, description, bar); + const previousFocus = document.activeElement; + document.body.append(dialog); + return new Promise((resolve) => { + confirmationPending = dialog; + const finish = (accepted) => { + dialog.close(); dialog.remove(); confirmationPending = null; + if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true }); + resolve(accepted); + }; + cancel.onclick = () => finish(false); + accept.onclick = () => finish(true); + dialog.addEventListener("cancel", (event) => { event.preventDefault(); finish(false); }); + dialog.addEventListener("click", (event) => { + const rect = dialog.getBoundingClientRect(); + if (event.target === dialog && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) finish(false); + }); + // Keep the underlying editor's Escape handlers from seeing this dialog. + dialog.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.key !== "Tab") return; + const boundary = event.shiftKey ? cancel : accept; + if (document.activeElement !== boundary) return; + event.preventDefault(); + (event.shiftKey ? accept : cancel).focus(); + }); + // Open after the triggering Escape has finished, so it cannot cancel this dialog. + requestAnimationFrame(() => { + dialog.showModal(); + cancel.focus({ preventScroll: true }); + }); + }); +} +function confirmRemoval(name, consequence = "This removes the selected item. Continue?") { + return confirmAction(t("Remove {name}?", { name }), t(consequence), t("Remove")); +} +function confirmDiscard() { + return confirmAction(t("Discard"), t("Discard unsaved changes? Your changes will be lost."), t("Discard")); +} +async function cancelEdit(keep) { + if ((providerDirty() || ((importing || importingApps) && modalFormDirty())) && !(await confirmDiscard())) return false; if (keep !== true) dropSigningIn(editing); editing = null; draft = null; importing = null; importingApps = null; renderProviders(); + return true; } +window.addEventListener("beforeunload", (e) => { + if (providerDirty() || window.routingDirty?.() || ((view === "library" || importing || importingApps) && modalFormDirty())) { + e.preventDefault(); + e.returnValue = ""; + } +}); // priceTypedError: a model with no list price given only part of its // price in Names & levels (its input and output both needed) holds the @@ -6844,6 +6929,45 @@ function fromRect(from, to) { const dx = from.left + from.width / 2 - (to.left + to.width / 2), dy = from.top + from.height / 2 - (to.top + to.height / 2); return `translate(${dx}px, ${dy}px) scale(${s})`; } +let modalReturnFocus = null, modalFormBase = ""; +function formValues(box) { + return JSON.stringify([ + [...box.querySelectorAll("input:not([type=search]), textarea, select")].map((input) => + [input.type, input.type === "checkbox" || input.type === "radio" ? input.checked : input.value]), + [...box.querySelectorAll(".segs button")].map((button) => button.getAttribute("aria-pressed")), + ]); +} +function modalFormValues() { return formValues($("#modal")); } +function modalFormDirty() { + return !$("#modal").hidden && !!modalFormBase && modalFormValues() !== modalFormBase; +} +function markModalSaved() { modalFormBase = modalFormValues(); } +let modalBackgroundElements = []; +function modalBackground(inert) { + if (inert) { + // A fresh editor may interrupt its closing animation; keep the original background. + if (modalBackgroundElements.length) return; + const modal = $("#modal"); + modalBackgroundElements = [...document.body.children].filter((element) => + element !== modal && element.tagName !== "SCRIPT" && !element.inert); + for (const element of modalBackgroundElements) element.inert = true; + } else { + for (const element of modalBackgroundElements) element.inert = false; + modalBackgroundElements = []; + } +} +$("#modal").addEventListener("keydown", (e) => { + if (e.key !== "Tab") return; + const modal = e.currentTarget; + const controls = [...modal.querySelectorAll('button, input, select, textarea, a[href], [tabindex="0"]')] + .filter((element) => !element.disabled && element.getClientRects().length && !element.closest("[inert]")); + if (!controls.length) { e.preventDefault(); modal.querySelector(".dialog").focus(); return; } + const first = controls[0], last = controls.at(-1); + const boundary = e.shiftKey ? first : last; + if (document.activeElement !== boundary && modal.contains(document.activeElement)) return; + e.preventDefault(); + (e.shiftKey ? last : first).focus(); +}); function openModal(content) { const m = $("#modal"), d = m.firstElementChild; const fresh = m.hidden || m.classList.contains("out"); @@ -6855,8 +6979,26 @@ function openModal(content) { d.classList.remove("swap"); if (!fresh) { void d.offsetWidth; d.classList.add("swap"); } // content changed: a soft refresh, not a re-entrance frame(content); + // Keep focus through a redraw of an open editor. + const active = d.contains(document.activeElement) ? document.activeElement : null; + const focusID = active?.id; + const focusLabel = active?.getAttribute("aria-label"); + const focusIndex = active ? [...d.querySelectorAll("input, textarea, select, button")].indexOf(active) : -1; + if (fresh) modalReturnFocus = document.activeElement; d.replaceChildren(content); + d.setAttribute("role", "dialog"); + d.setAttribute("aria-modal", "true"); + d.tabIndex = -1; + const title = content.querySelector(".ehead b, .mk-name, h2, h3"); + if (title) { title.id = "modalTitle"; d.setAttribute("aria-labelledby", title.id); d.removeAttribute("aria-label"); } + else { d.removeAttribute("aria-labelledby"); d.setAttribute("aria-label", t("Settings")); } m.hidden = false; + if (fresh) modalBackground(true); + if (fresh) markModalSaved(); + const focus = focusID && content.querySelector("#" + CSS.escape(focusID)) + || focusLabel && [...content.querySelectorAll("[aria-label]")].find((element) => element.getAttribute("aria-label") === focusLabel) + || focusIndex >= 0 && content.querySelectorAll("input, textarea, select, button")[focusIndex]; + (focus || content.querySelector("input:not([disabled]), textarea, select, button:not([disabled])") || d).focus({ preventScroll: true }); const body = content.querySelector(":scope > .ebody"); if (body) body.scrollTop = top; // a re-render keeps the place const nextNames = content.querySelector(".mnames:not([hidden])"); @@ -6887,6 +7029,8 @@ function closeModal() { if (m.hidden) return Promise.resolve(); if (m.classList.contains("out")) return modalDone || Promise.resolve(); m.classList.add("out"); + modalBackground(false); + modalFormBase = ""; for (const a of [...m.getAnimations(), ...d.getAnimations()]) a.commitStyles?.(), a.cancel(); // where it would sit at rest, whatever an opening cut short left it at const was = d.style.transform; @@ -6907,6 +7051,10 @@ function closeModal() { m.hidden = true; m.classList.remove("out"); d.replaceChildren(); + modalFormBase = ""; + if (modalReturnFocus?.isConnected && !modalReturnFocus.closest("[hidden]")) modalReturnFocus.focus({ preventScroll: true }); + else if (!$("#view-" + view).hidden) $("#view-" + view).focus({ preventScroll: true }); + modalReturnFocus = null; for (const a of [...m.getAnimations(), ...d.getAnimations()]) a.cancel(); d.style.opacity = d.style.transform = m.style.opacity = ""; // an editor opened from the add sheet gives the focus back to its option @@ -7006,7 +7154,14 @@ function renderEditor(p, presetID) { // its own icons, not the page's kept ones, which the rows after it take back const kept = keptIcons; keptIcons = null; - try { return drawEditor(p, presetID); } finally { keptIcons = kept; } + try { + const editor = drawEditor(p, presetID); + if (draft && draft !== providerDraftRef) { + providerDraftRef = draft; + providerDraftBase = providerDraftValue(); + } + return editor; + } finally { keptIcons = kept; } } function drawEditor(p, presetID) { const pr = presetID ? providers.presets.find((x) => x.id === presetID) : p?.preset ? providers.presets.find((x) => x.id === p.preset) : null; @@ -8669,6 +8824,7 @@ function renderModels(p) { const typed = draft.priceTyped?.[id]; const cell = (value, placeholder, k, label) => { const i = input(value, placeholder); + i.defaultValue = i.value; i.inputMode = "decimal"; i.dataset.part = k; i.setAttribute("aria-label", label); @@ -10879,7 +11035,10 @@ function resetInText(at) { // accountAction changes which account a provider uses, or which it has, // and leaves its editor open on the result. -async function accountAction(path, body, okMsg) { +async function accountAction(path, body, okMsg, confirmed = false) { + const key = path === "keys/remove" && providers?.providers?.find((p) => p.id === body.id)?.keyList?.find((k) => k.id === body.ref); + if (!confirmed && (path === "login/forget" || path === "keys/remove") && !await confirmRemoval(body.user || key?.name || key?.masked || body.ref, + "This account or key will stop being used for requests. You will need to add it again to use it.")) return false; try { providers = await api(path, body); renderProviders(); @@ -10962,6 +11121,8 @@ function keyWeight(p, k, w, total) { i.min = "1"; i.max = "1000"; i.className = "key-weight-in"; + i.setAttribute("aria-label", t("Key weight")); + i.title = b.title; let done = false; const save = (keep) => { if (done) return; @@ -11203,6 +11364,7 @@ function keyFoldRows(p, fold) { const q = input(keysOpen[p.id].q, t("Filter {n} keys — name, key, off, resting…", { n: all.length })); q.className = "keys-filter"; q.dataset.provider = p.id; + q.setAttribute("aria-label", q.placeholder); q.oninput = () => { keysOpen[p.id].q = q.value; const at = q.selectionStart; @@ -11215,18 +11377,13 @@ function keyFoldRows(p, fold) { fewer.onclick = () => { delete keysOpen[p.id]; renderProviders(); }; bar.append(q, count, el("span", "grow")); // removing many at once: those turned off, those failing for good, or - // those the filter matches; a second click does it + // those the filter matches; the confirmation names them before they go const removeMany = (label, keys) => { if (!keys.length || keys.length === all.length) return null; const b = el("button", "text keys-remove", label); - b.onclick = () => { - if (!b.dataset.armed) { - b.dataset.armed = "1"; - b.classList.add("danger"); - b.textContent = t("Remove {n} keys? Click again", { n: keys.length }); - setTimeout(() => { if (b.isConnected) { delete b.dataset.armed; b.classList.remove("danger"); b.textContent = label; } }, 4000); - return; - } + b.onclick = async () => { + if (!await confirmRemoval(keys.map((k) => k.name || k.masked || k.id).join(", "), + "This account or key will stop being used for requests. You will need to add it again to use it.")) return; b.classList.add("busy"); accountAction("keys/remove-many", { id: p.id, refs: keys.map((k) => k.id) }, t("{n} keys removed", { n: keys.length })); }; @@ -11518,6 +11675,8 @@ function renderMove(p) { } async function providerAction(action, body, okMsg, base = "provider/") { + if (action === "delete" && !await confirmRemoval(providers?.providers.find((p) => p.id === body.id)?.name || body.id, + "Its saved configuration will be removed. Agents using it may switch to another provider or their default model.")) return false; try { const was = editing; providers = await api(base + action, body); @@ -13357,7 +13516,7 @@ $("#modal").addEventListener("click", (e) => { if (confirmAsk && e.target === e.currentTarget) { e.stopImmediatePropagation(); closeConfirmAsk(); } }, true); document.addEventListener("keydown", (e) => { - if (confirmAsk && e.key === "Escape") { e.preventDefault(); e.stopImmediatePropagation(); closeConfirmAsk(); } + if (!confirmationPending && confirmAsk && e.key === "Escape") { e.preventDefault(); e.stopImmediatePropagation(); closeConfirmAsk(); } }, true); function resetOutcome(out) { switch (out.code) { @@ -15294,7 +15453,10 @@ function renderSessions() { seg.append(b); } } - for (const b of seg.querySelectorAll(":scope > .opt")) b.classList.toggle("on", b.dataset.agent === sessAgent); + for (const b of seg.querySelectorAll(":scope > .opt")) { + b.classList.toggle("on", b.dataset.agent === sessAgent); + b.setAttribute("aria-pressed", String(b.dataset.agent === sessAgent)); + } // and the agent picked is brought into the strip, the page left where it is const picked = seg.querySelector(":scope > .on"); if (picked && seg.scrollWidth > seg.clientWidth) { @@ -16877,7 +17039,9 @@ function tick(label, on) { function syncBar(ed, err, ...tools) { const bar = el("div", "bar"); bar.append(...tools); - ed.append(el("div", "editor-error", ""), bar); + const error = el("div", "editor-error", ""); + error.setAttribute("role", "alert"); + ed.append(error, bar); return (msg) => { ed.querySelector(".editor-error").textContent = msg || ""; }; } @@ -17109,6 +17273,7 @@ function renderTrayUsage(s, keep) { compactNum.className = "words compact-num"; compactNum.hidden = !!s.fullContext; compactNum.title = t("Compact at"); + compactNum.setAttribute("aria-label", t("Compact at")); compactNum.onchange = () => { const r = parseContexts(compactNum.value); const n = r.map?.["*"] || 0; @@ -18425,6 +18590,7 @@ addEventListener("click", (e) => { if (held && !holding) { holding = true; requestAnimationFrame(keepHeld); } }, true); for (const v of document.querySelectorAll(".view")) { + v.tabIndex = -1; v.addEventListener("scroll", () => { if (v.hidden) return; if (performance.now() < purposeUntil) { fitRoom(v); readerLeaves(v); } @@ -18433,11 +18599,37 @@ for (const v of document.querySelectorAll(".view")) { }, { passive: true }); } -function show(v) { +async function show(v) { // a page gateway mode leaves out (a link to it, an address kept) opens Providers if (gatewayMode && GATEWAY_HIDES.includes(v)) v = "providers"; + if (v !== view) { + if (view === "providers" && (editing !== null || importing || importingApps)) { + if (!await cancelEdit(true)) return false; + } else if (!$("#modal").hidden && !$("#modal").classList.contains("out")) { + if (view === "library" && modalFormDirty() && !(await confirmDiscard())) return false; + if (view === "library") window.closeLibraryModal?.(); else closeModal(); + } + if (view === "routing" && window.routingDirty?.()) { + if (!(await confirmDiscard())) return false; + window.discardRouting?.(); + } + if (view === "library" && window.libraryDirty?.()) { + if (!(await confirmDiscard())) return false; + window.discardLibrary?.(); + } + } view = v; - if (mode === "window") { for (const b of $("#nav").querySelectorAll("button")) b.classList.toggle("on", b.dataset.view === v); slide($("#nav"), "nav"); navInSight(); } + const title = ({ agents: "Agents", providers: "Providers", gateway: "Gateway", routing: "Routing", usage: "Usage", sessions: "Sessions", library: "Library", plugins: "Plugins", settings: "Settings" })[v]; + $("#pageTitle").dataset.en = title; + $("#pageTitle").textContent = t(title); + if (mode === "window") { + for (const b of $("#nav").querySelectorAll("button")) { + const on = b.dataset.view === v; + b.classList.toggle("on", on); + if (on) b.setAttribute("aria-current", "page"); else b.removeAttribute("aria-current"); + } + slide($("#nav"), "nav"); navInSight(); + } $("#prefs").classList.toggle("on", v === "settings"); for (const id of ["agents", "providers", "gateway", "routing", "usage", "sessions", "library", "plugins", "settings"]) $("#view-" + id).hidden = v !== id; // back to where the reader was in it, and again once it has what it loads @@ -18455,6 +18647,7 @@ function show(v) { if (v === "sessions") window.loadSessionsPage?.()?.then(back); syncTitle(); syncURL(); + return true; } // The tab, and the provider open in it, are kept in the address so a diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 30516390..dd40b6c3 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -51,6 +51,16 @@ const I18N = { "Replies": "回复", "Name the member in replies": "回复里写明成员", "A reply's model says magpie's provider/model that answered (workbuddy/glm-5.3-flash), not the vendor's own name, for agents that count usage by it. Claude Code, Claude Desktop and Codex keep the vendor's; the X-Magpie-Model header says it to every agent": "回复的 model 写 magpie 里作答的 provider/model(workbuddy/glm-5.3-flash),而不是厂商自己的名字,给按它统计用量的 agent 用。Claude Code、Claude Desktop 和 Codex 仍拿到厂商的名字;X-Magpie-Model 响应头对所有 agent 都会写明", + "Discard unsaved changes? Your changes will be lost.": "是否放弃未保存的修改?放弃后,这些修改将丢失。", + "This removes the selected item. Continue?": "这将移除所选项目。是否继续?", + "Its saved configuration will be removed. Agents using it may switch to another provider or their default model.": "这将删除它的已保存配置。使用它的 Agent 可能切换到其他供应商或默认模型。", + "This account or key will stop being used for requests. You will need to add it again to use it.": "此账户或密钥将不再用于请求。要再次使用,需要重新添加。", + "This routing group will no longer be available to agents.": "此路由组将不再供 Agent 使用。", + "These routing groups will no longer be available to agents.": "这些路由组将不再供 Agent 使用。", + "Key weight": "密钥权重", + "Saved middleware options will be removed. The plugin will run with no options.": "已保存的中间件选项将被移除。插件将不带选项运行。", + "It will be removed from the library and the agents it was given to.": "这将从资源库及使用它的 Agent 中移除它。", + "Main navigation": "主导航", "Gateway keys": "网关密钥", "Gateway key": "网关密钥", "Limit": "限额", @@ -974,7 +984,6 @@ const I18N = { "Filter {n} keys — name, key, off, resting…": "筛选 {n} 个密钥:名称、密钥、已停用、休息中…", "{n} of {m} keys": "{m} 个密钥中的 {n} 个", "Show fewer": "收起", - "Remove {n} keys? Click again": "移除 {n} 个密钥?再点一次", "Hide {n}, of every provider? Click again": "隐藏所有供应商的 {n} 个模型?再点一次", "Show {n} hidden, of every provider? Click again": "显示所有供应商已隐藏的 {n} 个模型?再点一次", "{n} keys removed": "已移除 {n} 个密钥", @@ -2322,7 +2331,6 @@ const I18N = { "Pick several groups to remove together": "选出几个组一起移除", "Select every group": "全选", "Pick the groups to remove": "勾选要移除的组", - "Remove {n}? Click again": "移除 {n} 个?再点一次", "{n} groups removed": "已移除 {n} 个组", "No group yet. New group makes one of any models you like.": "还没有路由组。「新建组」可组合任意模型。", "Find groups on their own": "自动创建路由组", @@ -4382,7 +4390,6 @@ const I18N = { "Filter {n} keys — name, key, off, resting…": "{n} 個のキーを絞り込む:名前、キー、オフ、休止中…", "{n} of {m} keys": "{m} 個中 {n} 個のキー", "Show fewer": "折りたたむ", - "Remove {n} keys? Click again": "{n} 個のキーを削除?もう一度クリック", "Hide {n}, of every provider? Click again": "全プロバイダーの {n} 個を非表示?もう一度クリック", "Show {n} hidden, of every provider? Click again": "全プロバイダーの非表示 {n} 個を表示?もう一度クリック", "{n} keys removed": "{n} 個のキーを削除しました", @@ -5734,7 +5741,6 @@ const I18N = { "Pick several groups to remove together": "複数のグループを選んでまとめて削除", "Select every group": "すべて選択", "Pick the groups to remove": "削除するグループを選択", - "Remove {n}? Click again": "{n} 件を削除?もう一度クリック", "{n} groups removed": "{n} 件のグループを削除しました", "No group yet. New group makes one of any models you like.": "グループはまだありません。「新しいグループ」で好きなモデルをまとめられます。", "Find groups on their own": "グループを自動で作成", @@ -6829,6 +6835,16 @@ const I18N = { "At its cap · {agent} still uses it": "上限に到達 · {agent} は引き続き使用中", "A usage window is at {n}%, past this account's {cap}% cap: requests through magpie are refused with a usage-cap error until it renews": "利用枠が {n}% に達し、このアカウントの上限 {cap}% を超えています。更新されるまで、magpie 経由のリクエストは使用量上限エラーで拒否されます", "{agent} is signed in to this account and asks its vendor itself, not through magpie, so with no other account on to move it to, {agent} goes on using it past the cap. Add another account, or pick {agent}'s models via magpie, for the cap to hold it": "{agent} はこのアカウントでサインインしており、magpie を経由せず自分でベンダーに要求します。切り替え先となる有効なアカウントが他にないため、{agent} は上限を超えても使い続けます。上限を効かせるには、別のアカウントを追加するか、{agent} のモデルを magpie 経由で選んでください", + "Discard unsaved changes? Your changes will be lost.": "未保存の変更を破棄しますか?変更は失われます。", + "This removes the selected item. Continue?": "選択した項目を削除します。続行しますか?", + "Its saved configuration will be removed. Agents using it may switch to another provider or their default model.": "保存した設定が削除されます。使用中のエージェントは別のプロバイダーまたは既定のモデルに切り替わる場合があります。", + "This account or key will stop being used for requests. You will need to add it again to use it.": "このアカウントまたはキーはリクエストに使用されなくなります。再び使うには追加し直してください。", + "This routing group will no longer be available to agents.": "このルーティンググループはエージェントから利用できなくなります。", + "These routing groups will no longer be available to agents.": "これらのルーティンググループはエージェントから利用できなくなります。", + "Key weight": "キーの重み", + "Saved middleware options will be removed. The plugin will run with no options.": "保存したミドルウェアのオプションが削除されます。プラグインはオプションなしで実行されます。", + "It will be removed from the library and the agents it was given to.": "ライブラリと使用中のエージェントから削除されます。", + "Main navigation": "メインナビゲーション", }, de: { "Give skills as": "Skills geben als", @@ -7790,7 +7806,6 @@ const I18N = { "Filter {n} keys — name, key, off, resting…": "{n} Schlüssel filtern — Name, Schlüssel, aus, pausiert…", "{n} of {m} keys": "{n} von {m} Schlüsseln", "Show fewer": "Weniger anzeigen", - "Remove {n} keys? Click again": "{n} Schlüssel entfernen? Nochmal klicken", "Hide {n}, of every provider? Click again": "{n} ausblenden, aller Anbieter? Nochmal klicken", "Show {n} hidden, of every provider? Click again": "{n} ausgeblendete aller Anbieter anzeigen? Nochmal klicken", "{n} keys removed": "{n} Schlüssel entfernt", @@ -9142,7 +9157,6 @@ const I18N = { "Pick several groups to remove together": "Mehrere Gruppen auswählen und zusammen entfernen", "Select every group": "Alle Gruppen auswählen", "Pick the groups to remove": "Zu entfernende Gruppen auswählen", - "Remove {n}? Click again": "{n} entfernen? Nochmal klicken", "{n} groups removed": "{n} Gruppen entfernt", "No group yet. New group makes one of any models you like.": "Noch keine Gruppe. „Neue Gruppe“ fasst beliebige Modelle zusammen.", "Find groups on their own": "Gruppen automatisch finden", @@ -10235,6 +10249,16 @@ const I18N = { "look for a plugin": "nach einer Erweiterung suchen", ", or ": ", oder ", "credits": "Credits", + "Discard unsaved changes? Your changes will be lost.": "Nicht gespeicherte Änderungen verwerfen? Ihre Änderungen gehen verloren.", + "This removes the selected item. Continue?": "Das ausgewählte Element wird entfernt. Fortfahren?", + "Its saved configuration will be removed. Agents using it may switch to another provider or their default model.": "Die gespeicherte Konfiguration wird entfernt. Agenten, die sie verwenden, können zu einem anderen Anbieter oder ihrem Standardmodell wechseln.", + "This account or key will stop being used for requests. You will need to add it again to use it.": "Dieses Konto oder dieser Schlüssel wird nicht mehr für Anfragen verwendet. Um es erneut zu verwenden, müssen Sie es wieder hinzufügen.", + "This routing group will no longer be available to agents.": "Diese Weiterleitungsgruppe steht Agenten nicht mehr zur Verfügung.", + "These routing groups will no longer be available to agents.": "Diese Weiterleitungsgruppen stehen Agenten nicht mehr zur Verfügung.", + "Key weight": "Schlüsselgewicht", + "Saved middleware options will be removed. The plugin will run with no options.": "Die gespeicherten Middleware-Optionen werden entfernt. Das Plugin läuft ohne Optionen.", + "It will be removed from the library and the agents it was given to.": "Es wird aus der Bibliothek und den Agenten entfernt, denen es zugewiesen wurde.", + "Main navigation": "Hauptnavigation", }, }; diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index cd167bce..b3e6b44b 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -16,13 +16,16 @@
+ +