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 ------------------------------------------------------------