From a8f5b08e40be866f052f01d72954e4f2b2790cf8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 02:09:47 +0000 Subject: [PATCH 1/2] Add generator selection groups, group management, and chip-row collapse to UI Co-authored-by: Ernest French --- MultiImageClient/Ui/wwwroot/app.js | 242 ++++++++++++++++++++++- MultiImageClient/Ui/wwwroot/index.html | 29 ++- MultiImageClient/Ui/wwwroot/style.css | 53 +++++ MultiImageClient/Workflows/UiWorkflow.cs | 11 ++ 4 files changed, 329 insertions(+), 6 deletions(-) diff --git a/MultiImageClient/Ui/wwwroot/app.js b/MultiImageClient/Ui/wwwroot/app.js index 1019eef..a633545 100644 --- a/MultiImageClient/Ui/wwwroot/app.js +++ b/MultiImageClient/Ui/wwwroot/app.js @@ -471,6 +471,13 @@ async function loadConfig() { for (const g of generators) { (g.kind === "describe" ? describeRow : gensRow).appendChild(buildGenChip(g)); } + // Selection-group buttons derive their key sets from the freshly loaded + // catalog (defaultOn / defaultOnWithImage), so they rebuild with the chips; + // the persisted collapse state re-applies for the same reason. + renderGenGroupButtons(); + renderGenGroupSettingsList(); + applyGenRowCollapse(); + applyDescribeRowCollapse(); // Visibility (needs an attached image + at least one describe target) is // owned by updateGeneratorCompatibility, called next. updateGeneratorCompatibility(); @@ -571,8 +578,10 @@ function updateShapeOptionLabel() { function updateGeneratorCount() { // The visible "N of M enabled" counter was removed (2026-07-31), but every // generator-selection change still funnels through here, so this remains - // the recompute point for the prompt-length notice. + // the recompute point for the prompt-length notice and the collapsed-row + // selection summaries. updatePromptLimitNotice(); + updateCollapsedNotes(); } // ---------- prompt length limits (non-blocking) ---------- @@ -682,6 +691,21 @@ function loadUiSettings() { activityTop: Number.isFinite(saved.activityTop) ? saved.activityTop : null, activityWidth: Number.isFinite(saved.activityWidth) ? saved.activityWidth : null, activityHeight: Number.isFinite(saved.activityHeight) ? saved.activityHeight : null, + // Named generator-selection groups saved by the user: [{name, keys[]}]. + // Keys are generator catalog keys; unknown keys (a target renamed or + // removed server-side) simply have no chip to check when applied. + genGroups: Array.isArray(saved.genGroups) + ? saved.genGroups + .filter((g) => g && typeof g.name === "string" && g.name.trim() !== "" && Array.isArray(g.keys)) + .map((g) => ({ name: g.name, keys: g.keys.filter((k) => typeof k === "string") })) + : [], + // Group-bar buttons the user chose to hide: builtin ids ("all", "none", + // "invert", "defaults", "image-defaults") or "custom:". + hiddenGenGroups: Array.isArray(saved.hiddenGenGroups) + ? saved.hiddenGenGroups.filter((x) => typeof x === "string") + : [], + gensRowCollapsed: saved.gensRowCollapsed === true, + describeRowCollapsed: saved.describeRowCollapsed === true, }; } catch { return { @@ -700,6 +724,10 @@ function loadUiSettings() { activityTop: null, activityWidth: null, activityHeight: null, + genGroups: [], + hiddenGenGroups: [], + gensRowCollapsed: false, + describeRowCollapsed: false, }; } } @@ -1405,6 +1433,218 @@ el("describe-enable-all").addEventListener("click", () => setAllDescribers(true) el("describe-disable-all").addEventListener("click", () => setAllDescribers(false)); el("opt-shape").addEventListener("change", updateGeneratorCompatibility); +// ---------- generator selection groups + chip-row collapse ---------- + +// Group buttons apply a complete media-generator selection in one click: +// the two built-in defaults (text jobs / image-input jobs, keyed off the +// server catalog's defaultOn / defaultOnWithImage) plus any groups the user +// saved from their own selection. Groups cover the MEDIA section only — the +// describe section keeps its own all/none so a group can't silently fan an +// image out to paid describe endpoints. All of it persists per-browser in +// uiSettings (genGroups / hiddenGenGroups / *RowCollapsed). + +const genGroupsBar = el("gen-groups"); +const genGroupsList = el("gen-groups-list"); + +function builtinGenGroups() { + return [ + { + id: "defaults", + label: "defaults", + title: "The standard default selection for text-to-image jobs (what a fresh window starts with)", + keys: generators.filter((g) => g.defaultOn).map((g) => g.key), + }, + { + id: "image-defaults", + label: "image defaults", + title: "The default selection for jobs with an input image: the same tiers with text-only Ideogram V4 swapped for image-capable Ideogram V3", + keys: generators.filter((g) => g.defaultOnWithImage).map((g) => g.key), + }, + ]; +} + +function customGroupId(name) { + return `custom:${name}`; +} + +function genGroupHidden(id) { + return uiSettings.hiddenGenGroups.includes(id); +} + +function setGenGroupHidden(id, hidden) { + const without = uiSettings.hiddenGenGroups.filter((x) => x !== id); + uiSettings.hiddenGenGroups = hidden ? [...without, id] : without; + saveUiSettings(); + renderGenGroupButtons(); + renderGenGroupSettingsList(); +} + +// Applying a group is a complete selection statement: listed models turn on, +// everything else in the media section turns off. Disabled chips (provider +// unavailable, or Recraft's AR-override gap on image jobs) stay unchecked — +// same rule as every other bulk action. +function applyGenGroup(keys) { + const wanted = new Set(keys); + for (const cb of gensRow.querySelectorAll("input")) { + cb.checked = wanted.has(cb.value) && !cb.disabled; + cb.closest(".gen-toggle").classList.toggle("checked", cb.checked); + } + updateGeneratorCount(); +} + +function genGroupKeysSummary(keys) { + const known = keys.map((k) => genLabel(k)); + return known.length ? known.join(", ") : "(no models)"; +} + +function renderGenGroupButtons() { + // The three basic actions are static buttons; visibility is the only thing + // managed here (data-group-id carries their settings identity). + for (const btn of document.querySelectorAll("#gen-controls [data-group-id]")) { + btn.hidden = genGroupHidden(btn.dataset.groupId); + } + genGroupsBar.innerHTML = ""; + for (const group of builtinGenGroups()) { + if (genGroupHidden(group.id)) continue; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "gen-group-btn"; + btn.textContent = group.label; + btn.title = `${group.title}: ${genGroupKeysSummary(group.keys)}`; + btn.addEventListener("click", () => applyGenGroup(group.keys)); + genGroupsBar.appendChild(btn); + } + for (const group of uiSettings.genGroups) { + if (genGroupHidden(customGroupId(group.name))) continue; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "gen-group-btn custom"; + btn.textContent = group.name; + btn.title = `Your saved group: ${genGroupKeysSummary(group.keys)}`; + btn.addEventListener("click", () => applyGenGroup(group.keys)); + genGroupsBar.appendChild(btn); + } +} + +function saveCurrentSelectionAsGroup() { + const keys = [...gensRow.querySelectorAll("input:checked")].map((cb) => cb.value); + if (keys.length === 0) { + alert("Select at least one model first — the group saves your current selection."); + return; + } + const name = (window.prompt(`Name for this group (${keys.length} model${keys.length === 1 ? "" : "s"}):`) || "").trim(); + if (!name) return; + if (builtinGenGroups().some((g) => g.label === name) || ["all", "none", "invert"].includes(name)) { + alert(`"${name}" is a built-in button name — pick another.`); + return; + } + // Same name = overwrite (that's how you update a group), and saving always + // un-hides it so the result is visible immediately. + uiSettings.genGroups = [ + ...uiSettings.genGroups.filter((g) => g.name !== name), + { name, keys }, + ]; + uiSettings.hiddenGenGroups = uiSettings.hiddenGenGroups.filter((x) => x !== customGroupId(name)); + saveUiSettings(); + renderGenGroupButtons(); + renderGenGroupSettingsList(); +} + +el("gen-group-save").addEventListener("click", saveCurrentSelectionAsGroup); + +function renderGenGroupSettingsList() { + if (!genGroupsList) return; + genGroupsList.innerHTML = ""; + const addRow = (id, label, detail, deletable) => { + const row = document.createElement("div"); + row.className = "gen-group-row"; + const toggle = document.createElement("label"); + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = !genGroupHidden(id); + cb.addEventListener("change", () => setGenGroupHidden(id, !cb.checked)); + toggle.appendChild(cb); + const nameNode = document.createElement("strong"); + nameNode.textContent = label; + toggle.appendChild(nameNode); + if (detail) { + const detailNode = document.createElement("span"); + detailNode.className = "gen-group-keys"; + detailNode.textContent = detail; + toggle.appendChild(detailNode); + } + row.appendChild(toggle); + if (deletable) { + const del = document.createElement("button"); + del.type = "button"; + del.className = "gen-group-delete"; + del.textContent = "delete"; + del.title = "Delete this saved group"; + del.addEventListener("click", () => { + if (!confirm(`Delete the saved group "${label}"?`)) return; + uiSettings.genGroups = uiSettings.genGroups.filter((g) => g.name !== label); + uiSettings.hiddenGenGroups = uiSettings.hiddenGenGroups.filter((x) => x !== customGroupId(label)); + saveUiSettings(); + renderGenGroupButtons(); + renderGenGroupSettingsList(); + }); + row.appendChild(del); + } + genGroupsList.appendChild(row); + }; + addRow("all", "all", "enable every available model", false); + addRow("none", "none", "disable every model", false); + addRow("invert", "invert", "invert the current selection", false); + for (const group of builtinGenGroups()) { + addRow(group.id, group.label, genGroupKeysSummary(group.keys), false); + } + for (const group of uiSettings.genGroups) { + addRow(customGroupId(group.name), group.name, genGroupKeysSummary(group.keys), true); + } +} + +// Chip-row collapse: hidden chips leave the group buttons as the whole +// chooser (the compact view). Selection state lives in the checkboxes either +// way — collapsing changes only what's displayed. +const gensRowToggle = el("gens-row-toggle"); +const gensCollapsedNote = el("gens-collapsed-note"); +const describeRowToggle = el("describe-row-toggle"); +const describeCollapsedNote = el("describe-collapsed-note"); + +function updateCollapsedNotes() { + const genCount = gensRow.querySelectorAll("input:checked").length; + gensCollapsedNote.textContent = `${genCount} selected`; + const descCount = describeRow.querySelectorAll("input:checked").length; + describeCollapsedNote.textContent = `${descCount} selected`; +} + +function applyGenRowCollapse() { + gensRow.hidden = uiSettings.gensRowCollapsed; + gensCollapsedNote.hidden = !uiSettings.gensRowCollapsed; + gensRowToggle.textContent = uiSettings.gensRowCollapsed ? "show models" : "hide models"; + gensRowToggle.setAttribute("aria-expanded", String(!uiSettings.gensRowCollapsed)); + updateCollapsedNotes(); +} + +function applyDescribeRowCollapse() { + describeRow.hidden = uiSettings.describeRowCollapsed; + describeCollapsedNote.hidden = !uiSettings.describeRowCollapsed; + describeRowToggle.textContent = uiSettings.describeRowCollapsed ? "show" : "hide"; + describeRowToggle.setAttribute("aria-expanded", String(!uiSettings.describeRowCollapsed)); + updateCollapsedNotes(); +} + +gensRowToggle.addEventListener("click", () => { + uiSettings.gensRowCollapsed = !uiSettings.gensRowCollapsed; + saveUiSettings(); + applyGenRowCollapse(); +}); +describeRowToggle.addEventListener("click", () => { + uiSettings.describeRowCollapsed = !uiSettings.describeRowCollapsed; + saveUiSettings(); + applyDescribeRowCollapse(); +}); + // ---------- image attach: paste / drop / browse (up to maxInputImages) ---------- const inputThumbs = el("input-thumbs"); diff --git a/MultiImageClient/Ui/wwwroot/index.html b/MultiImageClient/Ui/wwwroot/index.html index c339871..2111105 100644 --- a/MultiImageClient/Ui/wwwroot/index.html +++ b/MultiImageClient/Ui/wwwroot/index.html @@ -7,7 +7,7 @@ the Referer header of outbound links (error-hint links, new tabs). --> MultiImageClient - + @@ -94,6 +94,13 @@

MultiImageClient

+
+ model group buttons +

Each shown group appears as a one-click selection button beside the + model chips. Save new groups there with "save group…"; here you can hide buttons you don't use + (including the built-in ones) and delete your own saved groups.

+
+

Stored only in this browser (localStorage). Hidden jobs still exist on the server and on disk; other browsers keep their own settings.

@@ -218,13 +225,22 @@

MultiImageClient

- - - + + + + + + +
+ +
@@ -438,6 +457,6 @@

Make video from this image

- + diff --git a/MultiImageClient/Ui/wwwroot/style.css b/MultiImageClient/Ui/wwwroot/style.css index dd16f82..bdadd78 100644 --- a/MultiImageClient/Ui/wwwroot/style.css +++ b/MultiImageClient/Ui/wwwroot/style.css @@ -159,6 +159,40 @@ header h1 { margin: 0; font-size: 20px; letter-spacing: 0.5px; } } .settings-note { margin: 0; font-size: 12px; color: var(--accent-dark); } +/* Model group buttons management (settings panel): one row per group with a + shown/hidden checkbox; user-saved groups add a delete button. */ +#gen-groups-settings { display: flex; flex-direction: column; gap: 6px; font-size: 13px; } +.gen-groups-settings-note { margin: 0; font-size: 12px; color: var(--accent-dark); } +#gen-groups-list { display: flex; flex-direction: column; gap: 4px; } +.gen-group-row { display: flex; align-items: center; gap: 8px; } +.gen-group-row label { + display: flex; + align-items: baseline; + gap: 7px; + cursor: pointer; + min-width: 0; +} +.gen-group-row input { flex: none; align-self: center; } +.gen-group-row .gen-group-keys { + font-size: 11px; + color: var(--accent-dark); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.gen-group-row .gen-group-delete { + margin-left: auto; + font: inherit; + font-size: 11px; + color: var(--err); + background: var(--card); + border: 1px solid var(--err); + border-radius: 3px; + padding: 1px 8px; + cursor: pointer; +} +.gen-group-row .gen-group-delete:hover { color: #fff; background: var(--err); } + body.logs-open { overflow: hidden; } #logs-panel { position: fixed; @@ -745,6 +779,22 @@ main { max-width: 1280px; margin: 0 auto; padding: 18px 22px 60px; } cursor: pointer; } #gen-controls button:hover { color: #fff; background: var(--accent); } +/* Group buttons flow inline with the basic actions; #gen-groups is a pure + grouping node so its children join the same flex row. */ +#gen-groups { display: contents; } +/* Saved-selection group buttons read as presets, not actions: green accent + (semantic color, not gray) separates them from all/none/invert. */ +#gen-controls .gen-group-btn { + color: var(--ok); + border-color: var(--ok); +} +#gen-controls .gen-group-btn:hover { color: #fff; background: var(--ok); } +#gen-group-save { font-weight: 400; } +/* The chip-row collapse toggle sits at the far right of the controls row; + the note beside it summarizes the hidden selection. */ +#gens-row-toggle { margin-left: auto; font-weight: 400; } +#gens-collapsed-note { font-size: 12px; margin-left: auto; } +#gens-collapsed-note:not([hidden]) + #gens-row-toggle { margin-left: 0; } #gens-row { display: grid; @@ -789,6 +839,9 @@ main { max-width: 1280px; margin: 0 auto; padding: 18px 22px 60px; } background: var(--card); cursor: pointer; } #describe-head button:hover { border-color: var(--accent); } +#describe-row-toggle { margin-left: auto; } +#describe-collapsed-note { font-size: 11px; margin-left: auto; } +#describe-collapsed-note:not([hidden]) + #describe-row-toggle { margin-left: 0; } #describe-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(175px, 1fr)); diff --git a/MultiImageClient/Workflows/UiWorkflow.cs b/MultiImageClient/Workflows/UiWorkflow.cs index be3b0ce..2ff14d6 100644 --- a/MultiImageClient/Workflows/UiWorkflow.cs +++ b/MultiImageClient/Workflows/UiWorkflow.cs @@ -325,6 +325,17 @@ or UiJobRunner.KeyGrokWeb or UiJobRunner.KeyIdeogram or UiJobRunner.KeyBfl or UiJobRunner.KeyGoogle, + // Default set for image-input jobs (the "image defaults" + // group button): the same tiers with the text-only Ideogram + // V4 swapped for the image-capable Ideogram V3. This is a + // selection preset the user applies explicitly — attaching + // an image never rewrites the current selection. + defaultOnWithImage = g.key is UiJobRunner.KeyGpt2 + or UiJobRunner.KeyRecraft + or UiJobRunner.KeyGrokWeb + or UiJobRunner.KeyIdeogramV3 + or UiJobRunner.KeyBfl + or UiJobRunner.KeyGoogle, }) // Stable sort: available targets keep the intent order above, // unavailable ones trail in the same relative order. From defd61c3fdb19466802ed4ec3cd347cab33ec5f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 02:24:19 +0000 Subject: [PATCH 2/2] Fix chip-row collapse: [hidden] loses to display:grid on the row ids Co-authored-by: Ernest French --- MultiImageClient/Ui/wwwroot/style.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/MultiImageClient/Ui/wwwroot/style.css b/MultiImageClient/Ui/wwwroot/style.css index bdadd78..7fc339d 100644 --- a/MultiImageClient/Ui/wwwroot/style.css +++ b/MultiImageClient/Ui/wwwroot/style.css @@ -801,6 +801,9 @@ main { max-width: 1280px; margin: 0 auto; padding: 18px 22px 60px; } grid-template-columns: repeat(auto-fill, minmax(175px, 1fr)); gap: 8px; } +/* The collapse toggle sets the hidden attribute; without this the display:grid + above outranks the UA's [hidden] rule and the chips never disappear. */ +#gens-row[hidden], #describe-row[hidden] { display: none; } .gen-toggle { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); border-radius: 4px;