diff --git a/CHANGELOG.md b/CHANGELOG.md index cbf2afd149..7c1fd012fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ _In development — bullets added per PR; finalized at release._ - **feat(pricing): default pricing for Qwen `coder-model` on the `qw` provider** — the Qwen Coder Free (`qw`) registry already exposed the `coder-model` id (Qwen3.5/3.6 Coder Model) but `DEFAULT_PRICING.qw` was missing the row, so usage tracking reported `$0.00` for that model. The pricing row is now added with the same shape as the sibling `vision-model` tier, restoring non-zero cost tracking. Ported from upstream 9router PR [decolua/9router#156](https://github.com/decolua/9router/pull/156). (thanks @LinearSakana) - **feat(usage): Codex review-quota now surfaces the weekly window and the `additional_rate_limits` fallback shape** — the dashboard's Codex usage card showed only the **session** half of `code_review_rate_limit` and dropped review descriptors that arrived inside `additional_rate_limits` (the shape some ChatGPT Codex plans report). `buildCodexUsageQuotas` now emits the secondary window as `quotas.code_review_weekly` and, when the dedicated `code_review_rate_limit` block is empty, falls back to the matching descriptor in `additional_rate_limits` (matched on `limit_name`/`metered_feature`/`limit_id` containing `code_review` / `codex_review` / `review`). The new label `code_review_weekly → "Code Review Weekly"` is registered in `ProviderLimits/utils.tsx` so the card renders both windows side-by-side. The existing `quotas.code_review` key is preserved for back-compat. Inspired by upstream decolua/9router PR #836. (thanks @hiepau1231) - **feat(dashboard): per-provider dropdown filter on the quota dashboard** — the Quota dashboard now has a "Provider" dropdown alongside the existing Status / Type / Tier / Env filters. Choosing a provider narrows the visible accounts to that provider only; the selection persists in `localStorage` (`omniroute:limits:providerFilter`) and the dropdown auto-falls back to "All providers" if the persisted key no longer matches a connection in the current session. The dropdown only renders when there are at least two distinct providers in view, so single-provider setups aren't cluttered. The upstream "Expiring first" toggle is intentionally not ported — `visibleConnections` already always sorts by soonest reset within each status group, so the toggle would be redundant. Inspired by [decolua/9router#769](https://github.com/decolua/9router/pull/769) — thanks @DEYLNN. +- **feat(dashboard): "Done" button in the model picker during combo creation** — `ModelSelectModal` now supports a `keepOpenOnSelect` prop (opt-in, off by default). When set — and the combos page now sets it — picking a model no longer auto-closes the modal, and a full-width "Done" button is rendered in the modal footer so users can add several models in a row and confirm explicitly. Single-select callers (e.g. CLI tool cards) are unchanged: the prop is opt-in, so they keep auto-close. The existing `multiSelect` mode (Clear + Done footer driven by `selectedModels`) takes precedence over `keepOpenOnSelect` to avoid two competing footers. Inspired by upstream PR [decolua/9router#1031](https://github.com/decolua/9router/pull/1031). (thanks @zanuartri) ### 🐛 Fixed diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index a2d1669661..b01635f146 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -4335,6 +4335,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo title={t("addModelToCombo")} selectedModel={null} addedModelValues={models.map((m) => m.model)} + keepOpenOnSelect /> ); diff --git a/src/shared/components/ModelSelectModal.tsx b/src/shared/components/ModelSelectModal.tsx index 9736ed3866..8418168a0e 100644 --- a/src/shared/components/ModelSelectModal.tsx +++ b/src/shared/components/ModelSelectModal.tsx @@ -38,6 +38,15 @@ type ModelSelectModalProps = { multiSelect?: boolean; showCombos?: boolean; alwaysIncludeProviders?: string[] | null; + /** + * When true, picking a model does NOT auto-close the modal — the caller must close + * explicitly. A "Done" button is rendered in the modal footer so the user has a clear + * way to confirm they are finished adding entries. Useful in combo creation, where the + * user typically adds several models in a row. Mutually exclusive with `multiSelect` + * (which renders its own Clear + Done footer driven by `selectedModels`). + * Inspired by upstream PR decolua/9router#1031. + */ + keepOpenOnSelect?: boolean; }; export default function ModelSelectModal({ @@ -53,6 +62,7 @@ export default function ModelSelectModal({ multiSelect = false, showCombos = true, alwaysIncludeProviders = [], + keepOpenOnSelect = false, }: ModelSelectModalProps) { const t = useTranslations("common"); const resolvedTitle = title ?? t("selectModel"); @@ -328,12 +338,29 @@ export default function ModelSelectModal({ const handleSelect = (model: any) => { onSelect(model); - if (!multiSelect) { + if (!multiSelect && !keepOpenOnSelect) { onClose(); setSearchQuery(""); } }; + // Footer "Done" button for single-select callers that opted out of auto-close + // (e.g. combo creation, where users add several models in a row). Skipped when + // `multiSelect` is on — that mode renders its own Clear + Done footer below the body. + const doneFooter = + keepOpenOnSelect && !multiSelect ? ( + + ) : null; + return ( {/* Search - compact */}
diff --git a/tests/unit/ui/model-select-modal-keep-open.test.tsx b/tests/unit/ui/model-select-modal-keep-open.test.tsx new file mode 100644 index 0000000000..3d835824bd --- /dev/null +++ b/tests/unit/ui/model-select-modal-keep-open.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment jsdom +// +// Regression coverage for the "keepOpenOnSelect" prop added in feat/port-pr-1031. +// Mirrors the UX of upstream PR decolua/9router#1031: when a caller (e.g. combo +// creation) opts out of the auto-close-on-select behaviour, the modal renders a +// "Done" button in the footer so the user has a clear way to confirm they are +// finished adding entries. + +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const { default: ModelSelectModal } = await import("@/shared/components/ModelSelectModal"); + +const containers: HTMLElement[] = []; + +async function renderModal(props: Partial> = {}) { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + + const root = createRoot(container); + await act(async () => { + root.render( + {}} + onSelect={() => {}} + showCombos={false} + activeProviders={[]} + {...props} + /> + ); + }); + + await act(async () => {}); + return container; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url === "/api/provider-nodes") { + return { ok: true, json: async () => ({ nodes: [] }) }; + } + if (url === "/api/provider-models") { + return { ok: true, json: async () => ({ models: {} }) }; + } + if (url === "/api/combos") { + return { ok: true, json: async () => ({ combos: [] }) }; + } + return { ok: true, json: async () => ({}) }; + }) + ); +}); + +afterEach(() => { + while (containers.length > 0) { + containers.pop()?.remove(); + } + document.body.innerHTML = ""; + vi.unstubAllGlobals(); +}); + +describe("ModelSelectModal keepOpenOnSelect", () => { + it("does not render the Done button by default (auto-close behaviour preserved)", async () => { + const container = await renderModal(); + const doneButton = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "done" + ); + expect(doneButton).toBeUndefined(); + }); + + it("renders the Done button when keepOpenOnSelect is true", async () => { + const container = await renderModal({ keepOpenOnSelect: true }); + const doneButton = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "done" + ); + expect(doneButton).toBeDefined(); + }); + + it("clicking Done triggers onClose without invoking onSelect again", async () => { + const onClose = vi.fn(); + const onSelect = vi.fn(); + const container = await renderModal({ keepOpenOnSelect: true, onClose, onSelect }); + + const doneButton = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "done" + ); + expect(doneButton).toBeDefined(); + + await act(async () => { + doneButton!.click(); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("does not render the Done button when multiSelect is true (multiSelect owns its own footer)", async () => { + // multiSelect already ships a Clear + Done footer driven by selectedModels. + // keepOpenOnSelect must defer to it to avoid two competing Done buttons. + const container = await renderModal({ keepOpenOnSelect: true, multiSelect: true }); + const doneButtons = Array.from(container.querySelectorAll("button")).filter( + (b) => b.textContent?.trim() === "done" + ); + // Exactly one Done button — the one inside the multiSelect footer, not a duplicate. + expect(doneButtons.length).toBe(1); + }); +});