Skip to content
Merged
50 changes: 49 additions & 1 deletion webviews/src/agent-session/acpmux/QuickModelMenu.gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ type Props = {
anchorTop?: boolean;
anchorMiddle?: boolean;
routeLayout?: QuickMenu["routeLayout"];
widthLook?: QuickMenu["widthLook"];
/** Leo's rail: Claude Code with claude-sr2 and a work login, Codex with its app server and an SR route. */
routes?: boolean;
};

const models = (family: string, names: string[]) =>
Expand Down Expand Up @@ -65,6 +68,31 @@ const families: QuickFamily[] = [
{ id: "claude-sr", name: "Claude Code (sr)", mark: "claude", models: [], more: [], launch: true },
];

/// The rail Leo saw as nine tabs (2026-11-01: "we have too many harnesses because you didn't put
/// those things into accounts or whatever"): each Claude and OpenAI route is its own harness entry.
const routeFamilies: QuickFamily[] = [
families[0]!,
{
id: "claude-sr2",
name: "claude-sr2",
mark: "claude",
models: models("claude-sr2", ["Opus 5.5", "Sonnet 5.5"]),
more: [],
},
families[1]!,
{
id: "codex-app-server",
name: "Codex (app server)",
mark: "codex",
models: models("codex-app-server", ["GPT-6.1-Sol"]),
more: [],
},
{ id: "codex-sr2", name: "codex-sr2", mark: "codex", models: [], more: [], launch: true },
{ id: "cursor", name: "Cursor", mark: "cursor", models: models("cursor", ["Composer 2"]), more: [] },
{ id: "claude-work", name: "Claude Code (work)", mark: "claude", models: [], more: [], launch: true },
families[2]!,
];

const extras: Pick<QuickMenu, "effort" | "fast"> = {
effort: {
levels: ["low", "medium", "high", "xhigh", "max"].map((id) => ({
Expand Down Expand Up @@ -106,6 +134,8 @@ export default componentEntry<Props>({
stress,
inlineEffort = false,
routeLayout,
widthLook,
routes,
}: Props) {
const [isOpen, setOpen] = useState(false);
const trigger = useRef<HTMLButtonElement | null>(null);
Expand All @@ -126,7 +156,9 @@ export default componentEntry<Props>({
more: [],
})),
]
: families;
: routes
? routeFamilies
: families;
const list = tab ? [...available].sort((a, b) => (a.id === tab ? -1 : b.id === tab ? 1 : 0)) : available;
return (
<OnNewTab.Provider value={!chat}>
Expand Down Expand Up @@ -154,6 +186,7 @@ export default componentEntry<Props>({
inlineEffort,
...(placement ? { harnessPlacement: placement } : {}),
routeLayout,
...(widthLook ? { widthLook } : {}),
search: {
find: (query) =>
list
Expand Down Expand Up @@ -248,6 +281,21 @@ export default componentEntry<Props>({
// As a chat's composer opens it: each row's effort dropdown on (ModelPicker passes inlineEffort).
"in-chat": { props: { chat: true, inlineEffort: true }, play: open },
combined: { props: { chat: true }, play: open },
// The compact menu's widths (Leo 2026-11-01: "surely we can get more compact horizontally").
"width-fit": { props: { chat: true, inlineEffort: true, widthLook: "fit" }, play: open },
"width-stable": { props: { chat: true, inlineEffort: true, widthLook: "stable" }, play: open },
"width-snug": { props: { chat: true, inlineEffort: true, widthLook: "snug" }, play: open },
"width-wide": { props: { chat: true, inlineEffort: true, widthLook: "wide" }, play: open },
// One tab per harness; its routes are its Account choices.
"routes-one-tab": { props: { chat: true, inlineEffort: true, routes: true }, play: open },
"routes-account": {
props: { chat: true, inlineEffort: true, routes: true },
play: async (ctx) => {
await open(ctx);
await ctx.focus({ role: "menuitem", name: /^Account/ });
await ctx.press("Enter");
},
},
"combined-fast": { props: { chat: true, fast: true }, play: open },
"harness-symbols": { props: { placement: "symbols" }, play: open },
"harness-symbols-many": { props: { placement: "symbols", many: true }, play: open },
Expand Down
62 changes: 57 additions & 5 deletions webviews/src/agent-session/acpmux/QuickModelMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { authText, canSessionLogin, type SessionAuth } from "./sessionAuth";
import React, { Fragment, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import React, {
Fragment,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { AgentMark, agentBrand, hasAgentMark } from "../shared/AgentMark";
import type { ChatAccountList, ChatAccounts } from "./chatAccounts";
import { isModelRouterKind } from "./routerKinds";
Expand Down Expand Up @@ -58,6 +68,14 @@ export type QuickMenu = {
/// Account presentation alternatives for the gallery; the compact submenu is the default. Every
/// layout but inline lists Cmux routers under a Provider heading inside the Account choices.
routeLayout?: "submenu" | "inline" | "grouped";
/// How wide the menu is, for the gallery's versions; `fit` is the default (Leo 2026-11-01: "i
/// also don't like the way this composer has so much space between stuff surely we can get more
/// compact horizontally" / "see claude code has that"). `fit`: as wide as the open tab's rows, as
/// Claude Code's menu is, with an effort column as wide as a level's name. `stable`: as wide as the
/// rows every tab shows. `snug`: `stable` with tighter columns and a lower floor. Each of these
/// keeps its widest width while open, so a tab switch never narrows it (#25796). `wide`: the fixed
/// 480px menu with the 112px effort column (before).
widthLook?: "fit" | "stable" | "snug" | "wide";
/// Auth for the selected session/route only, as acpmux last verified it.
auth?: { verdict: SessionAuth; onLogin?(): void; onRefresh?(): void };
/// Fast mode: the menu's last checkbox row, its one place. Absent where the running model has
Expand Down Expand Up @@ -382,6 +400,34 @@ export function QuickModelMenu({
const selected = rail.current?.querySelector<HTMLElement>('[aria-pressed="true"]');
selected?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
}, [open, tab]);
// A compact width never narrows while the menu is open: a tab switch keeps the
// menu's width and the rail's place (#25796), though each tab's rows (Account and Effort show only
// on the chat's tab) want their own. The floor is the widest the menu has been since it opened;
// the observer goes with the rail, so it ends when the menu closes.
const railWidth = useRef<ResizeObserver | null>(null);
const holdsWidth = menu.widthLook !== "wide";
const railRef = useCallback(
(node: HTMLFieldSetElement | null) => {
rail.current = node;
railWidth.current?.disconnect();
railWidth.current = null;
const popupNode = node?.closest<HTMLElement>(".ui-popup");
if (!popupNode || !holdsWidth || typeof ResizeObserver === "undefined") return;
let widest = 0;
popupNode.style.minWidth = "";
const observer = new ResizeObserver(() => {
const width = popupNode.getBoundingClientRect().width;
if (width <= widest) return;
widest = width;
// Never past the room the pane gives (min-width beats max-width): a pane that narrows while
// the menu is open, or a first reading before the boundary applies, keeps it inside.
popupNode.style.minWidth = `min(${width}px, var(--available-width, calc(100vw - 24px)))`;
});
observer.observe(popupNode);
railWidth.current = observer;
},
[holdsWidth],
);
const toTop = () => {
if (body.current) body.current.scrollTop = 0;
setScrolled(false);
Expand Down Expand Up @@ -1207,13 +1253,19 @@ export function QuickModelMenu({
// pushing the segments past the narrow chat boundary.
const widthProbe = families ? (
<div className="acpmux-quick-width-probe" aria-hidden="true">
{families.list
.flatMap((family) => [...family.models, ...family.more])
{((menu.widthLook ?? "fit") !== "fit" || !shownTab ? families.list : shownTab.routes)
// The rows a tab shows, never its Legacy models: the longest model name of any harness's
// Legacy list sized the whole menu, so every row's keys sat far from its name.
.flatMap((family) => (menu.widthLook === "wide" ? [...family.models, ...family.more] : family.models))
.map((model) => (
<div className="acpmux-quick-width-probe-row" key={`${model.family}\u0000${model.id}`}>
<span className="acpmux-menu-label">{model.name}</span>
{/* The effort column: its fixed width (CSS), never the levels side by side. */}
{menu.inlineEffort === true && <span className="acpmux-quick-width-probe-efforts" />}
{/* The star and the ⌘N key after it, so every tab's rows fit whichever tab shows. */}
<span
className={`acpmux-quick-width-probe-trail${families.stars ? " acpmux-quick-width-probe-starred" : ""}`}
/>
</div>
))}
</div>
Expand All @@ -1228,7 +1280,7 @@ export function QuickModelMenu({
flip={false}
align="start"
label={t("picker.model")}
className={`acpmux-quick-menu acpmux-quick-placement-${placement}${families ? " acpmux-quick-railed" : ""}${newTab ? " acpmux-quick-newtab" : ""}`}
className={`acpmux-quick-menu acpmux-quick-placement-${placement} acpmux-quick-width-${menu.widthLook ?? "fit"}${families ? " acpmux-quick-railed" : ""}${newTab ? " acpmux-quick-newtab" : ""}`}
boundary={CHAT_COLUMN}
// A native menu panel cannot draw the harness strip or the More models scroll area.
// Keep this combined surface inside the window in both directions.
Expand Down Expand Up @@ -1259,7 +1311,7 @@ export function QuickModelMenu({
{families ? (
<div className="acpmux-quick-layout">
{/* One harness strip at the chip-facing edge: its position stays put as rows change. */}
<fieldset ref={rail} className="acpmux-quick-rail" aria-label={t("picker.harness")}>
<fieldset ref={railRef} className="acpmux-quick-rail" aria-label={t("picker.harness")}>
{tabs.map((entry) => {
const family = entry === shownTab ? shown! : leadRoute(entry, families.current);
const name = entry.name;
Expand Down
18 changes: 18 additions & 0 deletions webviews/src/agent-session/acpmux/modelPicker.css
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,22 @@
.acpmux-quick-model-effort:has(> .acpmux-quick-effort-trigger) { --acpmux-quick-effort-width: var(--acpmux-quick-effort-column); }
.acpmux-quick-model-effort:has(> .acpmux-quick-star) { --acpmux-quick-star-column: 26px; }
.acpmux-quick-menu { --acpmux-quick-effort-column: 112px; }
/* Compact (Leo 2026-11-01: "i also don't like the way this composer has so much space between
stuff surely we can get more compact horizontally" / "see claude code has that"): the menu is as
wide as the rows its tabs show (QuickModelMenu's width probe leaves Legacy models out), and the
effort column is as wide as a level's name and chevron ("Extra high ›"), so a row's effort, star
and key sit close after its name. `fit` is the default; `wide` keeps the earlier menu. */
.acpmux-quick-menu:is(.acpmux-quick-width-fit, .acpmux-quick-width-stable) {
--acpmux-quick-effort-column: 88px;
min-width: min(240px, var(--available-width, calc(100vw - 24px)));
}
/* The rail's menu was a fixed 480px (#26615): the compact looks take their rows' width instead. */
.acpmux-quick-railed:is(.acpmux-quick-width-fit, .acpmux-quick-width-stable, .acpmux-quick-width-snug) { width: max-content; }
.acpmux-quick-menu.acpmux-quick-width-snug {
--acpmux-quick-effort-column: 76px;
min-width: min(200px, var(--available-width, calc(100vw - 24px)));
max-width: min(360px, var(--available-width, calc(100vw - 24px)));
}
.acpmux-quick-effort-now {
overflow: hidden;
text-overflow: ellipsis;
Expand Down Expand Up @@ -772,6 +788,8 @@
display: inline-flex;
gap: var(--ds-space-1);
}
.acpmux-quick-width-probe-trail { display: inline-block; width: 16px; }
.acpmux-quick-width-probe-trail.acpmux-quick-width-probe-starred { width: calc(16px + 26px); }

/* A model row's star at its trailing edge: shown on a starred row, else while the row is pointed at. */
.acpmux-quick-star {
Expand Down
47 changes: 47 additions & 0 deletions webviews/test/quick-model-menu-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,4 +604,51 @@ await requireBrowserLane("quick-model-menu-layout.test.ts", async () => {
await page.close();
}
});

test("the menu is as wide as the rows its tabs show; a long Legacy model never widens it", async () => {
// Leo 2026-11-01: "i also don't like the way this composer has so much space between stuff
// surely we can get more compact horizontally" / "see claude code has that".
const widths: Record<string, number> = {};
const gaps: Record<string, number> = {};
for (const variant of ["width-fit", "width-wide"]) {
const page = await open(variant, 900, 640);
try {
widths[variant] = (await bounds(page, ".acpmux-quick-menu")).width;
{
// The widest shown name to its row's ⌘N key: the band between them.
gaps[variant] = await page.evaluate(() => {
const gaps = [...document.querySelectorAll(".acpmux-quick-model-effort")].flatMap((row) => {
const name = row.querySelector(".acpmux-menu-label")?.getBoundingClientRect();
const key = row.querySelector(".ui-menu-shortcut")?.getBoundingClientRect();
return name && key ? [key.left - name.right] : [];
});
return Math.min(...gaps);
});
}
} finally {
await page.close();
}
}
expect(widths["width-fit"]!).toBeLessThan(widths["width-wide"]! - 40);
// The band shrinks by the width the fixed menu added, so effort and ⌘N sit close to the name.
expect(gaps["width-fit"]!).toBeLessThan(gaps["width-wide"]! - 60);
});

test("one tab per harness: Claude and Codex routes are Account choices, never tabs", async () => {
// Leo 2026-11-01: "we have too many harnesses because you didn't put those things into
// accounts or whatever".
const page = await open("routes-one-tab");
try {
const marks = await page
.locator(".acpmux-quick-rail-tab:not(.acpmux-quick-rail-add)")
.evaluateAll((tabs) => tabs.map((tab) => tab.getAttribute("aria-label")));
expect(marks.filter((name) => /claude/i.test(name ?? ""))).toHaveLength(1);
expect(marks.filter((name) => /codex/i.test(name ?? ""))).toHaveLength(1);
await page.getByRole("menuitem", { name: /^Account/ }).hover();
for (const route of ["claude-sr2", "Claude Code (work)"])
await page.getByRole("menuitemradio", { name: route, exact: true }).waitFor();
} finally {
await page.close();
}
});
});
Loading