Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion webviews/src/agent-session/acpmux/ModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ function quickSections(
): { shown: ModelChoice[]; more: ModelChoice[] } {
const { latest, older } = sectionsFor(entry, lang);
const all = groupChoices([...latest, ...older], model).filter(usable);
return relevantModelSections(all, entry?.top);
return relevantModelSections(all, entry?.top, model);
}

/// A harness's models, each once: its curated top models, else each family's newest (`latest`),
Expand Down
73 changes: 73 additions & 0 deletions webviews/src/agent-session/acpmux/QuickModelMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,79 @@ test("an empty harness list does not claim an empty search had no matches", asyn
expect(text).not.toBe("No matching models");
});

// https://github.com/manaflow-ai/cmux/issues/29915: effort showed only as "Medium >" on the running
// row; the chat's own tab drew a blank column on its other models until pointed at.
test("every model row with levels names one at rest, on the chat's own tab too", async () => {
const container = installDom();
await loadQuickModelMenu();
function OwnTabHarness() {
const trigger = useRef<HTMLButtonElement | null>(null);
const [open, setOpen] = useState(false);
return (
<QuickModelMenu
open={open}
onOpenChange={setOpen}
trigger={trigger}
chip={{ "data-menu": "model", label: "Model", title: "Model" }}
onMore={() => {}}
menu={{
inlineEffort: true,
models: [],
current: "opus",
onModel() {},
effort: {
current: "high",
levels: [
{ id: "low", name: "Low" },
{ id: "high", name: "High" },
],
onPick() {},
},
families: {
current: "claude",
onPick() {},
list: [
{
id: "claude",
name: "Claude",
more: [],
models: [
{ id: "opus", name: "Opus", family: "claude", efforts: ["low", "high"] },
{
id: "sonnet",
name: "Sonnet",
family: "claude",
efforts: ["low", "medium"],
defaultEffort: "medium",
},
],
},
],
},
}}
>
Model
</QuickModelMenu>
);
}
await act(async () => {
root = createRoot(container);
root.render(<OwnTabHarness />);
});
await act(async () => container.querySelector<HTMLButtonElement>("button[data-menu='model']")!.click());
await settle();
const rowOf = (name: string) =>
[...document.querySelectorAll<HTMLElement>(".acpmux-quick-model-effort")].find((node) =>
node.querySelector(".acpmux-menu-label")?.textContent?.includes(name),
)!;
const opus = rowOf("Opus");
expect(opus.hasAttribute("data-running")).toBe(true);
expect(opus.querySelector(".acpmux-quick-effort-now")?.textContent).toContain("High");
const sonnet = rowOf("Sonnet");
expect(sonnet.hasAttribute("data-held")).toBe(true);
expect(sonnet.querySelector(".acpmux-quick-effort-now")?.textContent).toContain("Medium");
});

// Leo 2026-10-31: "WHY THE FUCK IS THERE NO FUCKING FAST MODE FOR FUCKIGN CODEX". Another tab's
// models get the same Fast mode row and effort column as the chat's own: the row holds the choice
// (off as the menu opens) and a model picked there lands with it.
Expand Down
13 changes: 6 additions & 7 deletions webviews/src/agent-session/acpmux/QuickModelMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -597,14 +597,13 @@ export function QuickModelMenu({
pickRow({ ...model, effort: value });
};
const look = menu.effortLook ?? "list";
// A row of a tab other than the chat's runs nothing yet: its column names the level a pick
// lands (the model's recommended one), as the chat's running row names its own.
const held = !!families && model.family !== families.current;
// A row that does not run names the level a pick lands (the model's recommended one), as the
// running row names its own, on every tab: a blank column until pointed at hid that each model
// has an effort choice at all (https://github.com/manaflow-ai/cmux/issues/29915).
const shown = running
? choices.find((choice) => choice.id === effort?.current)
: held
? choices.find((choice) => choice.recommended)
: undefined;
: choices.find((choice) => choice.recommended);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Effort-capable models without defaultEffort still have no recommended choice, so shown stays undefined and the effort trigger remains hidden at rest. Derive and display the actual landing level for these rows so every supported model names its effort.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At webviews/src/agent-session/acpmux/QuickModelMenu.tsx, line 605:

<comment>Effort-capable models without `defaultEffort` still have no recommended choice, so `shown` stays undefined and the effort trigger remains hidden at rest. Derive and display the actual landing level for these rows so every supported model names its effort.</comment>

<file context>
@@ -597,14 +597,13 @@ export function QuickModelMenu({
-      : held
-        ? choices.find((choice) => choice.recommended)
-        : undefined;
+      : choices.find((choice) => choice.recommended);
+    const held = !running && !!shown;
     // The row stays one line: the model, then its effort item at the trailing edge (the running
</file context>

const held = !running && !!shown;
// The row stays one line: the model, then its effort item at the trailing edge (the running
// model's level and a chevron). Pointing at it or ArrowRight opens the model's levels as a side
// dropdown beside the row, a vertical list in its own popup (never clipped by the list's scroll,
Expand All @@ -615,7 +614,7 @@ export function QuickModelMenu({
key={valueOf(model)}
data-effort-look={look}
data-running={running ? "" : undefined}
data-held={held && shown ? "" : undefined}
data-held={held ? "" : undefined}
>
{name}
<Submenu
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export function uniqueRetryModels(models: readonly RetryModel[], current: string
/**
* Splits `models` into the rows shown at once and the ones under More models, one row per model.
* `top` is the same harness-reported menu the composer uses. Without a report, both menus
* show the newest available family members. A running older model stays under More models.
* show the newest available family members. With a report the running model keeps its row; without
* one a running older model stays under More models.
*/
export function retryModelSections(
models: readonly RetryModel[],
Expand All @@ -64,7 +65,7 @@ export function retryModelSections(
const kept = reported && unique.find((model) => sameModel(model, reported));
return kept ? [kept.id] : [];
});
const { shown, more } = relevantModelSections(candidates, mappedTop);
const { shown, more } = relevantModelSections(candidates, mappedTop, current);
const original = new Map(unique.map((model) => [model.id, model]));
return {
latest: shown.map((model) => original.get(model.id) ?? model),
Expand Down
6 changes: 4 additions & 2 deletions webviews/src/agent-session/acpmux/modelPicker.css
Original file line number Diff line number Diff line change
Expand Up @@ -705,12 +705,14 @@
font-size: var(--text-caption);
white-space: nowrap;
}
/* A row that is not the running one shows its effort item only while pointed at or reached; a row
of another tab (nothing runs there yet) always names the level its pick lands. */
/* Every row with levels names one: the running row its level, in the text color, every other row the
level its pick lands, muted. A row with no named level shows its item only while pointed at or
reached. */
.acpmux-quick-model-effort:not([data-running], [data-held]):not(:hover):not(:has([data-highlighted], [data-popup-open]))
.acpmux-quick-effort-trigger {
opacity: 0;
}
.acpmux-quick-model-effort[data-running] > .ui-menu-item.acpmux-quick-effort-trigger { color: var(--agent-text); }
/* The dropdown is as wide as its levels, not the model menu's 280px floor, so it fits beside the row. */
.acpmux-quick-menu.acpmux-quick-effort-menu { min-width: 0; }
.acpmux-quick-effort-menu .acpmux-quick-effort-segment { gap: var(--ds-space-2); }
Expand Down
39 changes: 39 additions & 0 deletions webviews/src/agent-session/acpmux/relevantModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,29 @@ function newer(a: Model, b: Model): boolean {
return variant(b) && !variant(a);
}

/** `a` is a strictly older version than `b`. A variant of the same version is not, nor is a model
* whose name and id carry no version (a bare alias). A snapshot date is no version part, so
* `claude-opus-4-5` is not older than its dated snapshot `claude-opus-4-5-20251101`. */
function olderVersion(a: Model, b: Model): boolean {
const release = (model: Model) => version(model).filter((part) => part < 100_000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: olderVersion takes version digits from the raw id, so [1m] tags and hyphenated snapshot dates count as version parts when the name has no version. Strip bracket tags and date suffixes before parsing so context variants stay with their base model and aliases are not folded under dated snapshots.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At webviews/src/agent-session/acpmux/relevantModels.ts, line 61:

<comment>`olderVersion` takes version digits from the raw id, so `[1m]` tags and hyphenated snapshot dates count as version parts when the name has no version. Strip bracket tags and date suffixes before parsing so context variants stay with their base model and aliases are not folded under dated snapshots.</comment>

<file context>
@@ -54,9 +54,29 @@ function newer(a: Model, b: Model): boolean {
+ *  whose name and id carry no version (a bare alias). A snapshot date is no version part, so
+ *  `claude-opus-4-5` is not older than its dated snapshot `claude-opus-4-5-20251101`. */
+function olderVersion(a: Model, b: Model): boolean {
+  const release = (model: Model) => version(model).filter((part) => part < 100_000);
+  const left = release(a);
+  const right = release(b);
</file context>

const left = release(a);
const right = release(b);
if (left.length === 0 || right.length === 0) return false;
for (let index = 0; index < Math.max(left.length, right.length); index++) {
const difference = (left[index] ?? 0) - (right[index] ?? 0);
if (difference) return difference < 0;
}
return false;
}

/** One product line: the declared family and the version-stripped id together, so a harness whose
* families are whole vendors ("OpenAI", "Google") never folds one product line under another. */
const lineKey = (model: Model) => `${familyKey(model)}\u0000${familyKey({ ...model, family: undefined })}`;

export function relevantModelSections<T extends Model>(
models: readonly T[],
top: readonly string[] | undefined,
running?: string,
): { shown: T[]; more: T[] } {
const available = (model: T) => (model.offers ?? [model]).some((offer) => !offer.unavailable);
const chosen = new Set<T>();
Expand All @@ -67,6 +87,25 @@ export function relevantModelSections<T extends Model>(
);
if (model && available(model)) chosen.add(model);
}
// A harness that reports its whole live list (Codex: every GPT generation) would otherwise
// list its tab flat while one that reports a short menu (Claude Code) folds the rest under
// Legacy models (https://github.com/manaflow-ai/cmux/issues/29915). A menu model of a declared
// catalog family whose product line the same menu also lists at a newer version folds, so every
// tab shows its lines' current versions. Context variants of one version stay, inferred families
// never fold, and the chat's running model keeps its row: folding never hides the live choice.
const isRunning = (model: T) =>
!!running && (model.offers ?? [model]).some((offer) => offer.id === running || offer.release === running);
const newest = new Map<string, T>();
for (const model of chosen) {
if (!model.family) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A versionless model that shares a product line with versioned models can become the line's newest and block folding. olderVersion returns false whenever either side lacks a version, so the newest-tracking loop never replaces a versionless first entry. Skip versionless models here so only versioned models seed newest.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At webviews/src/agent-session/acpmux/relevantModels.ts, line 100:

<comment>A versionless model that shares a product line with versioned models can become the line's `newest` and block folding. `olderVersion` returns false whenever either side lacks a version, so the newest-tracking loop never replaces a versionless first entry. Skip versionless models here so only versioned models seed `newest`.</comment>

<file context>
@@ -67,6 +87,25 @@ export function relevantModelSections<T extends Model>(
+      !!running && (model.offers ?? [model]).some((offer) => offer.id === running || offer.release === running);
+    const newest = new Map<string, T>();
+    for (const model of chosen) {
+      if (!model.family) continue;
+      const key = lineKey(model);
+      const previous = newest.get(key);
</file context>
Suggested change
if (!model.family) continue;
if (!model.family || !version(model).some((part) => part < 100_000)) continue;

const key = lineKey(model);
const previous = newest.get(key);
if (!previous || olderVersion(previous, model)) newest.set(key, model);
}
for (const model of chosen) {
const lead = model.family ? newest.get(lineKey(model)) : undefined;
if (lead && olderVersion(model, lead) && !isRunning(model)) chosen.delete(model);
}
} else {
const families = new Map<string, T>();
for (const model of models) {
Expand Down
59 changes: 59 additions & 0 deletions webviews/test/relevant-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,65 @@ test("a provider alias names the same grouped row and unavailable models stay fo
expect(result.more).toEqual([blocked, ...models]);
});

// https://github.com/manaflow-ai/cmux/issues/29915: Codex reports its whole live list as its menu,
// so its tab listed 13 models flat while Claude's folded the rest under Legacy models.
test("a harness menu that lists older versions of a catalog family folds them, keeping variants", () => {
const catalog = [
{ id: "gpt-6.1-sol", name: "GPT-6.1 Sol", family: "Sol" },
{ id: "gpt-6.1-terra", name: "GPT-6.1 Terra", family: "Terra" },
{ id: "gpt-6-sol", name: "GPT-6 Sol", family: "Sol" },
{ id: "gpt-5.6-terra", name: "GPT-5.6 Terra", family: "Terra" },
{ id: "gpt-6-astra", name: "GPT-6 Astra", family: "Astra" },
{ id: "gpt-5.5", name: "GPT-5.5", family: "GPT" },
{ id: "opus[1m]", name: "Opus 5.5 · 1M context", family: "Opus", release: "claude-opus-5-5" },
{ id: "opus", name: "Opus 5.5", family: "Opus", release: "claude-opus-5-5" },
{ id: "plain", name: "Plain", family: "Opus" },
];
const result = relevantModelSections(catalog, ids(catalog));
expect(ids(result.shown)).toEqual([
"gpt-6.1-sol",
"gpt-6.1-terra",
"gpt-6-astra",
"gpt-5.5",
"opus[1m]",
"opus",
"plain",
]);
expect(ids(result.more)).toEqual(["gpt-6-sol", "gpt-5.6-terra"]);
});

test("a vendor-wide family folds within one product line only", () => {
const catalog = [
{ id: "openai/gpt-6.1-sol", name: "GPT-6.1 Sol", family: "OpenAI" },
{ id: "openai/gpt-6-astra", name: "GPT-6 Astra", family: "OpenAI" },
{ id: "openai/gpt-5.4-mini", name: "GPT-5.4 mini", family: "OpenAI" },
{ id: "openai/gpt-6-sol", name: "GPT-6 Sol", family: "OpenAI" },
{ id: "gpt-5.6", name: "GPT-5.6", family: "Sol" },
{ id: "gpt-6-sol", name: "GPT-6 Sol", family: "Sol" },
];
const result = relevantModelSections(catalog, ids(catalog));
expect(ids(result.shown)).toEqual([
"openai/gpt-6.1-sol",
"openai/gpt-6-astra",
"openai/gpt-5.4-mini",
"gpt-5.6",
"gpt-6-sol",
]);
expect(ids(result.more)).toEqual(["openai/gpt-6-sol"]);
});

test("the running model keeps its row and a dated snapshot never folds its alias", () => {
const catalog = [
{ id: "gpt-6.1-terra", name: "GPT-6.1 Terra", family: "Terra" },
{ id: "gpt-5.6-terra", name: "GPT-5.6 Terra", family: "Terra" },
{ id: "claude-opus-4-5-20251101", name: "", family: "Opus" },
{ id: "claude-opus-4-5", name: "", family: "Opus" },
];
const result = relevantModelSections(catalog, ids(catalog), "gpt-5.6-terra");
expect(ids(result.shown)).toEqual(ids(catalog));
expect(ids(relevantModelSections(catalog, ids(catalog)).more)).toEqual(["gpt-5.6-terra"]);
});

test("without a harness menu, newest provider families are inferred without model-name allowlists", () => {
const catalog = [
{ id: "gpt-6-sol", name: "GPT-6-Sol" },
Expand Down
Loading