diff --git a/src/client/app.css b/src/client/app.css index b59e70a..d21659f 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -2445,25 +2445,6 @@ body.resizing { } /* The effort row hangs under the selected model in the picker, indented to belong to it, with its current value and a chevron into the level list. */ -.picker .effortrow { - display: flex; - align-items: center; - gap: 8px; - padding-left: 22px; -} -.picker .effortrow .name { - flex: 1; -} -.effortvalue { - color: var(--ink-faint); - font-size: 12.5px; -} -.picker .effortrow .chev { - width: 13px; - height: 13px; - color: var(--ink-faint); -} - /* ---- composer dock ----------------------------------------------------- */ .dock { border-top: 0; @@ -2815,21 +2796,21 @@ body.resizing { bottom: calc(100% + 6px); right: 0; z-index: 30; - min-width: 260px; + min-width: 264px; max-width: 340px; - max-height: 50vh; - overflow-y: auto; background: var(--bg-raise); border: 1px solid var(--rule-strong); border-radius: var(--radius-lg); padding: 6px; - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18); + box-shadow: 0 8px 30px rgb(0 0 0 / 18%); } .picker[hidden] { display: none; } .picker .opt { - display: block; + display: flex; + align-items: center; + gap: 10px; width: 100%; text-align: left; font: inherit; @@ -2839,23 +2820,97 @@ body.resizing { border-radius: var(--radius-md); padding: 8px 10px; cursor: pointer; + /* Hover is the only feedback a menu row gives, so it should arrive instantly + and leave gently — a fade-in makes a menu feel unresponsive under a moving + pointer, and a hard cut on the way out makes it feel twitchy. */ + transition: background 0.16s ease; } -.picker .opt:hover { +.picker .opt:hover, +.picker .opt:focus-visible { background: var(--bg-sunk); + transition-duration: 0s; + outline: none; } -.picker .opt[aria-selected="true"] { - background: color-mix(in srgb, var(--accent) 12%, var(--bg-raise)); +/* The row whose flyout is open stays lit, so the panel beside it has a visible + parent rather than floating next to nothing. */ +.picker .opt[aria-expanded="true"] { + background: var(--bg-sunk); +} +.picker .optbody { + flex: 1; + min-width: 0; } .picker .opt .name { + display: block; font-weight: 500; font-size: 14px; } .picker .opt .sub { + display: block; font-family: var(--mono); font-size: 11px; color: var(--ink-faint); margin-top: 2px; } +/* The current model is marked with a tick, not a filled background: the menu is + mostly one row, and a highlighted lone row reads as hover rather than state. */ +.picker .optcheck { + flex: none; + display: inline-flex; + color: var(--accent); +} +.picker .optcheck svg { + width: 15px; + height: 15px; +} +.picker .opt[aria-selected="true"] { + background: none; +} +.pickrule { + height: 1px; + margin: 5px 8px; + background: var(--rule); +} +/* Rows that lead somewhere: a value on the right, then a chevron. */ +.picker .siderow .chev { + flex: none; + width: 14px; + height: 14px; + color: var(--ink-faint); +} +.effortvalue { + flex: none; + color: var(--ink-faint); + font-size: 13px; +} +/* The rest of the models, beside the menu — anchored to the row that opened it + and opening away from the composer. */ +.pickflyout { + position: absolute; + right: calc(100% + 6px); + min-width: 220px; + max-height: 60vh; + overflow-y: auto; + background: var(--bg-raise); + border: 1px solid var(--rule-strong); + border-radius: var(--radius-lg); + padding: 6px; + box-shadow: 0 8px 30px rgb(0 0 0 / 18%); +} +/* No room beside it on a narrow screen: fall back to below, still anchored. */ +@media (max-width: 720px) { + .pickflyout { + position: static; + right: auto; + margin-top: 4px; + box-shadow: none; + border: 0; + border-top: 1px solid var(--rule); + border-radius: 0; + padding: 4px 0 0; + max-height: 40vh; + } +} .picker .none { padding: 10px; font-size: 13px; diff --git a/src/client/app.js b/src/client/app.js index 29c78a9..8cd89bb 100644 --- a/src/client/app.js +++ b/src/client/app.js @@ -3571,81 +3571,94 @@ import { mountSidebar } from "./sidebar.js"; picker.appendChild(none); return; } - models.forEach(function (m) { - var b = document.createElement("button"); - b.className = "opt"; - b.type = "button"; - b.setAttribute("role", "option"); - if (selected && m.ref === selected.ref) b.setAttribute("aria-selected", "true"); - var sub = []; - if (m.contextWindow) sub.push(fmtCtx(m.contextWindow) + " ctx"); - if (m.reasoningLevels && m.reasoningLevels.length) sub.push("reasoning"); - if (m.supportsImages) sub.push("images"); - b.innerHTML = - '
' + - (sub.length ? '
' + sub.join(" · ") + "
" : ""); - b.querySelector(".name").textContent = m.name; - b.onclick = function () { - selected = m; - localStorage.setItem("kloe.model", m.ref); - renderPill(); - renderPicker(); - updateSend(); - updateCtx(); - closePicker(); - }; - picker.appendChild(b); - // The effort row belongs to the model above it: a level list is only - // meaningful next to the model whose levels they are. - if (selected && m.ref === selected.ref && levelsOf(m).length) { - picker.appendChild(effortRow(m)); - } - }); + // The menu is about the model you are USING: it, what it can do, and the two + // things you might change. Every other model is one row away rather than + // eight rows down — a list of everything makes the common case (glance at + // what is selected, adjust effort) hunt through the rare one. + if (selected) picker.appendChild(modelRow(selected, true)); + if (selected && levelsOf(selected).length) { + picker.appendChild(divider()); + picker.appendChild(effortRow(selected)); + } + if (models.length > 1) { + picker.appendChild(divider()); + picker.appendChild(moreRow()); + } + } + function divider() { + var d = document.createElement("div"); + d.className = "pickrule"; + return d; + } + /** One model: its name, what it can do, and a tick when it is the current one. */ + function modelRow(m, isCurrent) { + var b = document.createElement("button"); + b.className = "opt"; + b.type = "button"; + b.setAttribute("role", "option"); + if (isCurrent) b.setAttribute("aria-selected", "true"); + var sub = []; + if (m.contextWindow) sub.push(fmtCtx(m.contextWindow) + " ctx"); + if (levelsOf(m).length) sub.push("reasoning"); + if (m.supportsImages) sub.push("images"); + b.innerHTML = + '' + + (sub.length ? '' + sub.join(" · ") + "" : "") + + ""; + b.querySelector(".name").textContent = m.name; + if (isCurrent) b.insertAdjacentHTML("beforeend", '' + CHECK + ""); + b.onclick = function () { + selected = m; + localStorage.setItem("kloe.model", m.ref); + renderPill(); + renderPicker(); + updateSend(); + updateCtx(); + closePicker(); + }; + return b; } - /** - * "Effort High >" — a row under the selected model that opens its levels. - * - * A submenu rather than a row per level in the main list: the levels are a - * property of one model, and inlining four of them would bury the other - * models under whichever one happened to be selected. - */ - function effortRow(m) { + /** "More models ›" — everything that isn't selected, in a panel to the side. */ + function moreRow() { var row = document.createElement("button"); - row.className = "opt effortrow"; + row.className = "opt siderow"; row.type = "button"; - var level = currentEffort(); - var value = document.createElement("span"); - value.className = "effortvalue"; - value.textContent = level ? effortLabel(level) : "Default"; - row.innerHTML = '
Effort
'; - row.appendChild(value); + row.innerHTML = 'More models'; row.insertAdjacentHTML("beforeend", CHEV); row.onclick = function (e) { e.stopPropagation(); - var r = row.getBoundingClientRect(); - var items = [ - { - label: "Default", - icon: level ? ICON_BLANK : CHECK, - onClick: function () { - setEffort(null); - }, - }, - ].concat( - levelsOf(m).map(function (l) { - return { - label: effortLabel(l), - icon: level === l ? CHECK : ICON_BLANK, - onClick: function () { - setEffort(l); - }, - }; - }), - ); - showContextMenu(r.right, r.top, items, { align: "left", trigger: row }); + toggleFlyout(row); }; return row; } + /** + * The rest of the models, beside the menu rather than inside it. + * + * Its own element rather than the shared context menu: these rows carry a + * second line (context window, reasoning, images), which is most of what you + * are choosing between, and a menu of bare labels would drop it. + */ + function toggleFlyout(anchor) { + var open = picker.querySelector(".pickflyout"); + if (open) { + open.remove(); + anchor.setAttribute("aria-expanded", "false"); + return; + } + var fly = document.createElement("div"); + fly.className = "pickflyout"; + models.forEach(function (m) { + if (selected && m.ref === selected.ref) return; // it is already the row above + fly.appendChild(modelRow(m, false)); + }); + picker.appendChild(fly); + anchor.setAttribute("aria-expanded", "true"); + // Open upward from the row that summoned it, and never off the top. + var pr = picker.getBoundingClientRect(); + var ar = anchor.getBoundingClientRect(); + fly.style.bottom = Math.max(0, pr.bottom - ar.bottom - 6) + "px"; + } + function openPicker() { picker.hidden = false; pill.setAttribute("aria-expanded", "true"); @@ -3653,6 +3666,10 @@ import { mountSidebar } from "./sidebar.js"; function closePicker() { picker.hidden = true; pill.setAttribute("aria-expanded", "false"); + // The flyout is a child of the picker, so hiding the picker hides it — but + // it must not be there on the next open, showing a list nobody asked for. + var fly = picker.querySelector(".pickflyout"); + if (fly) fly.remove(); } // ---- wiring ------------------------------------------------------------