Skip to content

Simplify model selector to Quick/Powerful#432

Merged
AnthonyRonning merged 3 commits intomasterfrom
model-selector-quick-powerful
Feb 24, 2026
Merged

Simplify model selector to Quick/Powerful#432
AnthonyRonning merged 3 commits intomasterfrom
model-selector-quick-powerful

Conversation

@marksftw
Copy link
Contributor

@marksftw marksftw commented Feb 24, 2026

Closes #431

Summary

  • Replace 5 task-based categories (General, Quick, Reasoning, Math/Coding, Image Analysis) with 2 primary options: Quick (llama-3.3-70b) and Powerful (kimi-k2-5)
  • Rename "Advanced" submenu to "More models"
  • Make image auto-switch tier-aware: Pro/Max/Team → Powerful (kimi-k2-5), Starter → Qwen3-VL 30B, Free → no auto-switch
  • Remove unused QUICK_MODEL_ID from LocalStateContext
  • Billing change that makes GPT-OSS a Free model
  • Billing change that sets GPT-OSS as the the "Quick" model

Test plan

  • Dropdown shows "Quick" and "Powerful" as primary options
  • "More models" opens full model list with back button
  • Button label shows "Quick" for gpt-oss-120b, "Powerful" for kimi-k2-5, model name for others
  • Image auto-switch works per tier (Pro→Powerful, Starter→Qwen3-VL, Free→no switch)
  • Upgrade prompt shown when free/starter user clicks Powerful
  • No gap at bottom of More models submenu
  • Free users can use both gpt-oss and llama

Summary by CodeRabbit

  • Improvements
    • Streamlined model selection UI to present primary "Quick" and "Powerful" options.
    • Enhanced automatic switching to a vision-capable model when users upload images (with safeguards to avoid unnecessary switches).
    • Unified default model behavior across subscription tiers for more consistent defaults.
    • Simplified model initialization and state handling for improved stability and predictability.

Replace 5 task-based categories with 2 primary options (Quick, Powerful)
and a "More models" submenu. Quick maps to llama-3.3-70b, Powerful maps
to kimi-k2-5. Image auto-switch is now tier-aware (Pro/Max/Team gets
Powerful, Starter gets Qwen3-VL, Free gets no auto-switch). Remove
unused QUICK_MODEL_ID from LocalStateContext.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Feb 24, 2026

📝 Walkthrough

Walkthrough

This change replaces category-based model selection with two primary options (Quick, Powerful), relabels the advanced view to "More models", adds billing-aware vision-capable auto-switching, and consolidates defaults by replacing QUICK_MODEL_ID with DEFAULT_MODEL_ID in local state handling.

Changes

Cohort / File(s) Summary
Model selector UI & logic
frontend/src/components/ModelSelector.tsx
Replaces CATEGORY_* with PRIMARY_* (Quick/Powerful); renames handlers to handlePrimarySelect; updates dropdown label/display, collapsed label logic, and "Advanced" → "More models"; adds billing-guarded vision auto-switch and updates active/disabled logic, badges, and lock indicators.
Local state / default model
frontend/src/state/LocalStateContext.tsx
Removes QUICK_MODEL_ID; uses DEFAULT_MODEL_ID for initial/default selection and billing-driven switches; renames llamaModeldefaultModel; initializes availableModels with defaultModel and aligns billing update flow.
Model listing & filtering
frontend/src/components/...
Advanced/"More models" panel now filters/sorts full model list with vision support and permission checks; conditional rendering of badges and lock icons based on capabilities and access.

Sequence Diagram(s)

sequenceDiagram
  participant UI as ModelSelector (UI)
  participant State as LocalStateContext
  participant Billing as BillingService
  participant Registry as ModelRegistry

  UI->>State: user selects "Quick" or "Powerful" / opens "More models"
  UI->>Registry: request model metadata (vision support, permissions)
  Registry-->>UI: model metadata
  alt image input present
    UI->>Billing: fetch billing status
    Billing-->>UI: billing tier (Free / Pro/Max/Team)
    UI->>State: request vision-capable model switch (if needed and allowed)
    State->>Registry: validate target model availability/permissions
    Registry-->>State: allowed? / metadata
    State-->>UI: confirm switch or show locked badge
  end
  UI->>State: set selected model
  State-->>UI: update active model / availableModels
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

  • OpenSecretCloud/master#21: Implements the same Quick/Powerful primary options, "More models" relabeling, and billing-aware vision auto-switching.

Possibly related PRs

  • OpenSecretCloud/Maple#299: Modifies ModelSelector and LocalStateContext around category→primary refactor and DEFAULT_MODEL_ID usage.
  • OpenSecretCloud/Maple#157: Touches default-model initialization and removal of QUICK_MODEL_ID similar to this change.
  • OpenSecretCloud/Maple#175: Alters billing-dependent selection logic that interacts with the auto-switch behavior introduced here.

Poem

🐰 Quick or Powerful, which will you pick?
Two carrots, one brain — both do the trick.
Billing checks, vision wakes, models align,
Defaults simplified, the interface fine.
Hop, click, choose — a happy little sign.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Linked Issues check ❓ Inconclusive The implementation shows primary model simplification aligned with issue #431, but there is a discrepancy: issue #431 specifies GPT-OSS 120B as Quick and Kimi K2.5 as Powerful, while the code implements llama-3.3-70b and kimi-k2-5 respectively. Confirm whether the backing models (llama vs GPT-OSS for Quick) represent an intentional deviation from #431 requirements or if updates are needed to match the specified models.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Simplify model selector to Quick/Powerful' directly and clearly summarizes the main change: replacing the previous category-based model selection with a simplified two-option interface.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the model selector simplification and supporting state management updates outlined in the PR objectives and linked issue #431.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch model-selector-quick-powerful

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines 206 to 225
useEffect(() => {
if (chatHasImages && hasAccessToModel(CATEGORY_MODELS.image)) {
// Only auto-switch if not already on a vision-capable model
const currentModelConfig = MODEL_CONFIG[model];
if (!currentModelConfig?.supportsVision) {
setModel(CATEGORY_MODELS.image);
}
if (!chatHasImages) return;
const currentModelConfig = MODEL_CONFIG[model];
if (currentModelConfig?.supportsVision) return; // Already on a vision model

const planName = billingStatus?.product_name?.toLowerCase() || "";
const isProMaxOrTeam =
planName.includes("pro") || planName.includes("max") || planName.includes("team");
const isStarter = planName.includes("starter");

if (isProMaxOrTeam) {
// Pro/Max/Team: switch to Powerful (kimi-k2-5 has vision)
setModel(PRIMARY_MODELS.powerful);
} else if (isStarter) {
// Starter: switch to qwen3-vl-30b
setModel("qwen3-vl-30b");
}
// Free: no auto-switch (existing upgrade prompt handles it)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatHasImages]);
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 Image auto-switch useEffect captures stale billingStatus (null) due to missing dependency

When a user uploads an image, the auto-switch useEffect reads billingStatus from its closure, but the dependency array is only [chatHasImages]. Since billingStatus starts as null (LocalStateContext.tsx:67) and is populated asynchronously via setBillingStatus after an API call, there is a race condition: if images are added before billing status loads, planName will be "", neither isProMaxOrTeam nor isStarter will be true, and no auto-switch occurs for any paid user. Once billingStatus later arrives, the effect does not re-run because chatHasImages hasn't changed.

Root Cause and Impact

The effect at ModelSelector.tsx:206-225 uses billingStatus (line 211) and model (line 208) from the component closure but only lists [chatHasImages] as a dependency (line 225), suppressing the exhaustive-deps lint rule.

The old code had the same [chatHasImages] dependency pattern, but it only called hasAccessToModel(CATEGORY_MODELS.image) which was a single yes/no gate. The new code introduces tier-aware branching (isProMaxOrTeam → kimi-k2-5, isStarter → qwen3-vl-30b) that is entirely dependent on billingStatus being populated. With a stale null value, the entire auto-switch feature silently fails for all paid tiers.

Impact: Pro/Max/Team users who upload images before billing status loads will not get auto-switched to the Powerful (kimi-k2-5) vision model. Starter users will similarly not get auto-switched to qwen3-vl-30b. The user would need to manually select a vision-capable model.

Suggested change
useEffect(() => {
if (chatHasImages && hasAccessToModel(CATEGORY_MODELS.image)) {
// Only auto-switch if not already on a vision-capable model
const currentModelConfig = MODEL_CONFIG[model];
if (!currentModelConfig?.supportsVision) {
setModel(CATEGORY_MODELS.image);
}
if (!chatHasImages) return;
const currentModelConfig = MODEL_CONFIG[model];
if (currentModelConfig?.supportsVision) return; // Already on a vision model
const planName = billingStatus?.product_name?.toLowerCase() || "";
const isProMaxOrTeam =
planName.includes("pro") || planName.includes("max") || planName.includes("team");
const isStarter = planName.includes("starter");
if (isProMaxOrTeam) {
// Pro/Max/Team: switch to Powerful (kimi-k2-5 has vision)
setModel(PRIMARY_MODELS.powerful);
} else if (isStarter) {
// Starter: switch to qwen3-vl-30b
setModel("qwen3-vl-30b");
}
// Free: no auto-switch (existing upgrade prompt handles it)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatHasImages]);
// Auto-switch to a vision-capable model when images are uploaded
useEffect(() => {
if (!chatHasImages) return;
const currentModelConfig = MODEL_CONFIG[model];
if (currentModelConfig?.supportsVision) return; // Already on a vision model
const planName = billingStatus?.product_name?.toLowerCase() || "";
const isProMaxOrTeam =
planName.includes("pro") || planName.includes("max") || planName.includes("team");
const isStarter = planName.includes("starter");
if (isProMaxOrTeam) {
// Pro/Max/Team: switch to Powerful (kimi-k2-5 has vision)
setModel(PRIMARY_MODELS.powerful);
} else if (isStarter) {
// Starter: switch to qwen3-vl-30b
setModel("qwen3-vl-30b");
}
// Free: no auto-switch (existing upgrade prompt handles it)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatHasImages, billingStatus]);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a pre-existing issue and out of scope for this PR.

Remove requiresPro restriction from gpt-oss-120b so it's free for
everyone. Set it as DEFAULT_MODEL_ID and the Quick primary option,
replacing llama-3.3-70b as the default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Feb 24, 2026

Deploying maple with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7959cb4
Status: ✅  Deploy successful!
Preview URL: https://a510c74f.maple-ca8.pages.dev
Branch Preview URL: https://model-selector-quick-powerfu.maple-ca8.pages.dev

View logs

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
frontend/src/state/LocalStateContext.tsx (1)

30-46: ⚠️ Potential issue | 🟡 Minor

Priority 2 billing check is now dead code; comment on line 45 is stale.

Since both Priority 2 (planName matches pro/max/team) and Priority 3 (the unconditional fallback) return DEFAULT_MODEL_ID, the entire cachedBillingStatus block is redundant — the result is the same regardless of cached billing plan. Additionally, the comment on line 45 says "Default to free model" but DEFAULT_MODEL_ID is now the universal default for all tiers, making it misleading.

♻️ Proposed simplification
 function getInitialModel(): string {
   // Check for dev override first
   if (import.meta.env.VITE_DEV_MODEL_OVERRIDE) {
     return aliasModelName(import.meta.env.VITE_DEV_MODEL_OVERRIDE);
   }

   try {
-    // Priority 1: Check local storage for last used model
+    // Check local storage for last used model
     const selectedModel = localStorage.getItem("selectedModel");
     if (selectedModel) {
       return aliasModelName(selectedModel);
     }
-
-    // Priority 2: Check cached billing status for pro/max/team users
-    const cachedBillingStr = localStorage.getItem("cachedBillingStatus");
-    if (cachedBillingStr) {
-      const cachedBilling = JSON.parse(cachedBillingStr) as BillingStatus;
-      const planName = cachedBilling.product_name?.toLowerCase() || "";
-
-      // Pro, Max, or Team users get default model
-      if (planName.includes("pro") || planName.includes("max") || planName.includes("team")) {
-        return DEFAULT_MODEL_ID;
-      }
-    }
   } catch (error) {
     console.error("Failed to load initial model:", error);
   }

-  // Priority 3: Default to free model
+  // Default model for all users
   return DEFAULT_MODEL_ID;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/state/LocalStateContext.tsx` around lines 30 - 46, The cached
billing branch (variables cachedBillingStr, cachedBilling, planName) is dead
code because it ultimately returns DEFAULT_MODEL_ID just like the unconditional
fallback; remove that entire Priority 2 block and its misleading comment, then
update the remaining comment to state that DEFAULT_MODEL_ID is the universal
default (or adjust the fallback if you intended different per-plan behavior);
ensure any JSON.parse usages around cachedBillingStr are also removed so there
are no unused variables left.
frontend/src/components/ModelSelector.tsx (1)

363-366: ⚠️ Potential issue | 🟡 Minor

showAdvanced is never reset when the dropdown dismisses without a selection

showAdvanced is component-level state that persists across dropdown open/close cycles. If the user navigates to "More models" then closes the dropdown by clicking outside or pressing Escape, showAdvanced remains true. The next time they open the dropdown they land directly on the "More models" panel instead of the primary Quick/Powerful view.

🛠️ Proposed fix — reset on close via `onOpenChange`
- <DropdownMenu>
+ <DropdownMenu onOpenChange={(open) => { if (!open) setShowAdvanced(false); }}>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/ModelSelector.tsx` around lines 363 - 366, The
component-level boolean showAdvanced persists when the dropdown closes without
selection; add an onOpenChange handler to the DropdownMenu component to reset
it: in DropdownMenu (wrapping DropdownMenuTrigger) implement
onOpenChange={(open) => { if (!open) setShowAdvanced(false); }} so whenever the
menu closes (including click-outside or Escape) you call setShowAdvanced(false)
and restore the primary Quick/Powerful panel.
🧹 Nitpick comments (4)
frontend/src/state/LocalStateContext.tsx (2)

51-57: defaultModel is redefined on every render unnecessarily.

The object depends only on the module-level constant DEFAULT_MODEL_ID and fixed string literals, so it can be hoisted to module scope.

♻️ Proposed fix — extract to module level

Add before LocalStateProvider:

+const defaultModel: OpenSecretModel = {
+  id: DEFAULT_MODEL_ID,
+  object: "model",
+  created: Date.now(),
+  owned_by: "openai",
+  tasks: ["generate"]
+};
+
 export const LocalStateProvider = ({ children }: { children: React.ReactNode }) => {
-  /** The model that should be assumed when a chat doesn't yet have one */
-  const defaultModel: OpenSecretModel = {
-    id: DEFAULT_MODEL_ID,
-    object: "model",
-    created: Date.now(),
-    owned_by: "openai",
-    tasks: ["generate"]
-  };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/state/LocalStateContext.tsx` around lines 51 - 57, The
defaultModel object is being recreated on every render inside
LocalStateProvider; hoist it to module scope by declaring a top-level const
defaultModel: OpenSecretModel = { id: DEFAULT_MODEL_ID, object: "model",
created: Date.now(), owned_by: "openai", tasks: ["generate"] } before the
LocalStateProvider definition and then remove the in-component declaration so
LocalStateProvider uses the module-level defaultModel constant.

183-188: Both branches call setModel(DEFAULT_MODEL_ID) — simplify into a single condition.

♻️ Proposed simplification
       if (shouldUpdateModel) {
-        if (isProMaxOrTeam) {
-          setModel(DEFAULT_MODEL_ID);
-        } else if (billingChanged) {
-          // User downgraded, switch back to free model
-          setModel(DEFAULT_MODEL_ID);
-        }
+        if (isProMaxOrTeam || billingChanged) {
+          setModel(DEFAULT_MODEL_ID);
+        }
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/state/LocalStateContext.tsx` around lines 183 - 188, The two
branches both call setModel(DEFAULT_MODEL_ID); simplify by combining their
conditions into a single if that checks (isProMaxOrTeam || billingChanged) and
then calls setModel(DEFAULT_MODEL_ID). Update the logic around the existing
conditional in LocalStateContext (referencing the isProMaxOrTeam and
billingChanged variables and the setModel(DEFAULT_MODEL_ID) call) so there is no
duplicate call path.
frontend/src/components/ModelSelector.tsx (2)

214-220: Bare string "qwen3-vl-30b" in auto-switch duplicates a known model ID

PRIMARY_MODELS already centralises the Quick/Powerful IDs. The Starter vision model used in auto-switch is a raw string that could silently diverge from MODEL_CONFIG if the model is ever renamed.

♻️ Suggested extraction
  const PRIMARY_MODELS = {
    quick: "gpt-oss-120b",
-   powerful: "kimi-k2-5"
+   powerful: "kimi-k2-5",
+   starterVision: "qwen3-vl-30b"
  };

  // ... in the effect:
- setModel("qwen3-vl-30b");
+ setModel(PRIMARY_MODELS.starterVision);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/ModelSelector.tsx` around lines 214 - 220, The
auto-switch currently uses a hard-coded string "qwen3-vl-30b" in the isStarter
branch which risks drifting from your canonical model IDs; replace that literal
with the centralized identifier (e.g., use PRIMARY_MODELS.starterVision or the
corresponding entry from MODEL_CONFIG) so the starter vision model is sourced
from the shared config instead of a bare string, updating the isStarter ->
setModel call to reference that constant (keep references to isProMaxOrTeam,
isStarter, setModel, and PRIMARY_MODELS/MODEL_CONFIG to locate the change).

457-470: model callback parameter shadows the outer model state variable

In three .some()/.findIndex() callbacks the parameter is named model, which shadows the model value from useLocalState(). It doesn't produce a bug here, but it reduces clarity and can catch future refactors off-guard.

♻️ Suggested rename
- .filter((m) => {
-   if (m.id === "leon-se/gemma-3-27b-it-fp8-dynamic") {
-     return !availableModels.some((model) => model.id === "gemma-3-27b");
-   }
-   if (m.id === "ibnzterrell/Meta-Llama-3.3-70B-Instruct-AWQ-INT4") {
-     return !availableModels.some((model) => model.id === "llama-3.3-70b");
-   }
-   return true;
- })
+ .filter((m) => {
+   if (m.id === "leon-se/gemma-3-27b-it-fp8-dynamic") {
+     return !availableModels.some((am) => am.id === "gemma-3-27b");
+   }
+   if (m.id === "ibnzterrell/Meta-Llama-3.3-70B-Instruct-AWQ-INT4") {
+     return !availableModels.some((am) => am.id === "llama-3.3-70b");
+   }
+   return true;
+ })
  // Remove duplicates by id
  .filter(
    (m, index, self) =>
      MODEL_CONFIG[m.id] !== undefined &&
-     self.findIndex((model) => model.id === m.id) === index
+     self.findIndex((am) => am.id === m.id) === index
  )

Also, the MODEL_CONFIG[m.id] !== undefined check on line 469 is redundant — the first .filter on line 455 already guarantees this.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/ModelSelector.tsx` around lines 457 - 470, The
callbacks inside the two filters in ModelSelector.tsx shadow the outer "model"
state from useLocalState() — rename the inner callback parameters in the .some()
and .findIndex() calls (e.g., use "candidate" or "otherModel" instead of
"model") to avoid shadowing and improve clarity; additionally remove the
redundant MODEL_CONFIG[m.id] !== undefined check from the second .filter since
the earlier filter already enforces valid MODEL_CONFIG entries. Ensure
references to availableModels.some(...) and self.findIndex(...) use the new
parameter names.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/src/components/ModelSelector.tsx`:
- Around line 203-223: The useEffect that auto-switches models when images are
uploaded reads billingStatus from a stale closure because its dependency array
only contains chatHasImages; add billingStatus to the dependency array and
restore the linter (remove the eslint-disable) so the effect re-runs when
billingStatus becomes available, and add an early return guard (e.g., if
(billingStatus == null) return) before computing planName to avoid premature
empty-plan behavior; keep model out of deps so user manual model changes aren't
overwritten, and continue using MODEL_CONFIG, setModel, and PRIMARY_MODELS as in
the existing code.

---

Outside diff comments:
In `@frontend/src/components/ModelSelector.tsx`:
- Around line 363-366: The component-level boolean showAdvanced persists when
the dropdown closes without selection; add an onOpenChange handler to the
DropdownMenu component to reset it: in DropdownMenu (wrapping
DropdownMenuTrigger) implement onOpenChange={(open) => { if (!open)
setShowAdvanced(false); }} so whenever the menu closes (including click-outside
or Escape) you call setShowAdvanced(false) and restore the primary
Quick/Powerful panel.

In `@frontend/src/state/LocalStateContext.tsx`:
- Around line 30-46: The cached billing branch (variables cachedBillingStr,
cachedBilling, planName) is dead code because it ultimately returns
DEFAULT_MODEL_ID just like the unconditional fallback; remove that entire
Priority 2 block and its misleading comment, then update the remaining comment
to state that DEFAULT_MODEL_ID is the universal default (or adjust the fallback
if you intended different per-plan behavior); ensure any JSON.parse usages
around cachedBillingStr are also removed so there are no unused variables left.

---

Nitpick comments:
In `@frontend/src/components/ModelSelector.tsx`:
- Around line 214-220: The auto-switch currently uses a hard-coded string
"qwen3-vl-30b" in the isStarter branch which risks drifting from your canonical
model IDs; replace that literal with the centralized identifier (e.g., use
PRIMARY_MODELS.starterVision or the corresponding entry from MODEL_CONFIG) so
the starter vision model is sourced from the shared config instead of a bare
string, updating the isStarter -> setModel call to reference that constant (keep
references to isProMaxOrTeam, isStarter, setModel, and
PRIMARY_MODELS/MODEL_CONFIG to locate the change).
- Around line 457-470: The callbacks inside the two filters in ModelSelector.tsx
shadow the outer "model" state from useLocalState() — rename the inner callback
parameters in the .some() and .findIndex() calls (e.g., use "candidate" or
"otherModel" instead of "model") to avoid shadowing and improve clarity;
additionally remove the redundant MODEL_CONFIG[m.id] !== undefined check from
the second .filter since the earlier filter already enforces valid MODEL_CONFIG
entries. Ensure references to availableModels.some(...) and self.findIndex(...)
use the new parameter names.

In `@frontend/src/state/LocalStateContext.tsx`:
- Around line 51-57: The defaultModel object is being recreated on every render
inside LocalStateProvider; hoist it to module scope by declaring a top-level
const defaultModel: OpenSecretModel = { id: DEFAULT_MODEL_ID, object: "model",
created: Date.now(), owned_by: "openai", tasks: ["generate"] } before the
LocalStateProvider definition and then remove the in-component declaration so
LocalStateProvider uses the module-level defaultModel constant.
- Around line 183-188: The two branches both call setModel(DEFAULT_MODEL_ID);
simplify by combining their conditions into a single if that checks
(isProMaxOrTeam || billingChanged) and then calls setModel(DEFAULT_MODEL_ID).
Update the logic around the existing conditional in LocalStateContext
(referencing the isProMaxOrTeam and billingChanged variables and the
setModel(DEFAULT_MODEL_ID) call) so there is no duplicate call path.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 571c279 and 7fefb93.

📒 Files selected for processing (2)
  • frontend/src/components/ModelSelector.tsx
  • frontend/src/state/LocalStateContext.tsx

Comment on lines +203 to 223
// Auto-switch to a vision-capable model when images are uploaded
useEffect(() => {
if (chatHasImages && hasAccessToModel(CATEGORY_MODELS.image)) {
// Only auto-switch if not already on a vision-capable model
const currentModelConfig = MODEL_CONFIG[model];
if (!currentModelConfig?.supportsVision) {
setModel(CATEGORY_MODELS.image);
}
if (!chatHasImages) return;
const currentModelConfig = MODEL_CONFIG[model];
if (currentModelConfig?.supportsVision) return; // Already on a vision model

const planName = billingStatus?.product_name?.toLowerCase() || "";
const isProMaxOrTeam =
planName.includes("pro") || planName.includes("max") || planName.includes("team");
const isStarter = planName.includes("starter");

if (isProMaxOrTeam) {
// Pro/Max/Team: switch to Powerful (kimi-k2-5 has vision)
setModel(PRIMARY_MODELS.powerful);
} else if (isStarter) {
// Starter: switch to qwen3-vl-30b
setModel("qwen3-vl-30b");
}
// Free: no auto-switch (existing upgrade prompt handles it)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatHasImages]);
Copy link

@coderabbitai coderabbitai bot Feb 24, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Stale billingStatus closure can silently skip the auto-switch

The effect's dependency array is [chatHasImages] only, but billingStatus is read from a stale closure. If billingStatus hasn't loaded yet when chatHasImages first becomes true (e.g., an image is pasted immediately on page load), planName evaluates to "", neither branch fires, and the effect never re-runs because chatHasImages stays true. The // eslint-disable-next-line react-hooks/exhaustive-deps comment is the direct signal of this problem.

Adding billingStatus to the dep array (with an early-return guard) fixes the race without the need to suppress the linter:

🛠️ Proposed fix
  useEffect(() => {
    if (!chatHasImages) return;
+   if (!billingStatus) return; // wait for billing to load before deciding
    const currentModelConfig = MODEL_CONFIG[model];
    if (currentModelConfig?.supportsVision) return;

    const planName = billingStatus?.product_name?.toLowerCase() || "";
    const isProMaxOrTeam =
      planName.includes("pro") || planName.includes("max") || planName.includes("team");
    const isStarter = planName.includes("starter");

    if (isProMaxOrTeam) {
      setModel(PRIMARY_MODELS.powerful);
    } else if (isStarter) {
      setModel("qwen3-vl-30b");
    }
-   // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [chatHasImages]);
+ }, [chatHasImages, billingStatus]);

Note: intentionally keeping model out of deps is correct — including it would cause the effect to fight any manual model switch the user makes while images are present.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Auto-switch to a vision-capable model when images are uploaded
useEffect(() => {
if (chatHasImages && hasAccessToModel(CATEGORY_MODELS.image)) {
// Only auto-switch if not already on a vision-capable model
const currentModelConfig = MODEL_CONFIG[model];
if (!currentModelConfig?.supportsVision) {
setModel(CATEGORY_MODELS.image);
}
if (!chatHasImages) return;
const currentModelConfig = MODEL_CONFIG[model];
if (currentModelConfig?.supportsVision) return; // Already on a vision model
const planName = billingStatus?.product_name?.toLowerCase() || "";
const isProMaxOrTeam =
planName.includes("pro") || planName.includes("max") || planName.includes("team");
const isStarter = planName.includes("starter");
if (isProMaxOrTeam) {
// Pro/Max/Team: switch to Powerful (kimi-k2-5 has vision)
setModel(PRIMARY_MODELS.powerful);
} else if (isStarter) {
// Starter: switch to qwen3-vl-30b
setModel("qwen3-vl-30b");
}
// Free: no auto-switch (existing upgrade prompt handles it)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatHasImages]);
// Auto-switch to a vision-capable model when images are uploaded
useEffect(() => {
if (!chatHasImages) return;
if (!billingStatus) return; // wait for billing to load before deciding
const currentModelConfig = MODEL_CONFIG[model];
if (currentModelConfig?.supportsVision) return; // Already on a vision model
const planName = billingStatus?.product_name?.toLowerCase() || "";
const isProMaxOrTeam =
planName.includes("pro") || planName.includes("max") || planName.includes("team");
const isStarter = planName.includes("starter");
if (isProMaxOrTeam) {
// Pro/Max/Team: switch to Powerful (kimi-k2-5 has vision)
setModel(PRIMARY_MODELS.powerful);
} else if (isStarter) {
// Starter: switch to qwen3-vl-30b
setModel("qwen3-vl-30b");
}
// Free: no auto-switch (existing upgrade prompt handles it)
}, [chatHasImages, billingStatus]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/ModelSelector.tsx` around lines 203 - 223, The
useEffect that auto-switches models when images are uploaded reads billingStatus
from a stale closure because its dependency array only contains chatHasImages;
add billingStatus to the dependency array and restore the linter (remove the
eslint-disable) so the effect re-runs when billingStatus becomes available, and
add an early return guard (e.g., if (billingStatus == null) return) before
computing planName to avoid premature empty-plan behavior; keep model out of
deps so user manual model changes aren't overwritten, and continue using
MODEL_CONFIG, setModel, and PRIMARY_MODELS as in the existing code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a pre-existing issue and out of scope for this PR.

Copy link

Choose a reason for hiding this comment

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

@marksftw, understood! Since this touches on functionality that could affect the image auto-switch behavior, would you like me to open a follow-up issue to track this for a future PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

no

Copy link

Choose a reason for hiding this comment

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

@marksftw, got it—I'll leave this as-is. Feel free to reach out if you need anything else!

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/ModelSelector.tsx (1)

363-366: ⚠️ Potential issue | 🟡 Minor

showAdvanced persists across dropdown sessions — reset it on close.

If a user navigates to "More models" and dismisses the dropdown (Escape or outside-click) without selecting, the next open still shows the full model list instead of the primary Quick/Powerful view.

🛠️ Proposed fix
-      <DropdownMenu>
+      <DropdownMenu onOpenChange={(open) => { if (!open) setShowAdvanced(false); }}>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/ModelSelector.tsx` around lines 363 - 366, The
dropdown's showAdvanced state (showAdvanced) isn't reset when the menu closes,
so add a close handler on the DropdownMenu (use its onOpenChange or equivalent
prop) that sets showAdvanced to false whenever open becomes false; update the
component to pass a function like onOpenChange={(open) => { if (!open)
setShowAdvanced(false); }} to the DropdownMenu (the DropdownMenu and
showAdvanced state/setShowAdvanced variables identify where to make the change).
🧹 Nitpick comments (2)
frontend/src/components/ModelSelector.tsx (2)

391-393: Inconsistent boolean | undefined type for isDisabled; use || false to match the advanced list pattern.

targetModelConfig?.disabled is boolean | undefined, making isDisabled boolean | undefined. The advanced-list code (line 505) correctly normalises with || false. Aligning here satisfies strict TypeScript and avoids a type mismatch with the disabled prop.

♻️ Proposed fix
-                const isDisabled = isDisabledDueToImages || targetModelConfig?.disabled;
+                const isDisabled = isDisabledDueToImages || (targetModelConfig?.disabled ?? false);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/ModelSelector.tsx` around lines 391 - 393, isDisabled
is inferred as boolean | undefined because targetModelConfig?.disabled can be
undefined; change its assignment in the ModelSelector component so it always
yields a boolean by normalizing the optional value (e.g., use "|| false").
Specifically update the expression that builds isDisabled from
isDisabledDueToImages and targetModelConfig?.disabled (and reference the
isDisabledDueToImages variable and targetModelConfig?.disabled) to coerce the
latter to a boolean (targetModelConfig?.disabled || false) so isDisabled is
strictly boolean and matches the advanced-list pattern.

1-1: Prefer ChevronRight over ChevronLeft rotate-180 for the "More models" row.

The rotation works but ChevronRight is semantically correct and avoids a confusing CSS transform for future readers.

♻️ Proposed fix
-import { ChevronDown, Check, Lock, Camera, ChevronLeft, Zap, Brain } from "lucide-react";
+import { ChevronDown, Check, Lock, Camera, ChevronLeft, ChevronRight, Zap, Brain } from "lucide-react";
-                <ChevronLeft className="h-4 w-4 opacity-70 rotate-180" />
+                <ChevronRight className="h-4 w-4 opacity-70" />

Also applies to: 427-427

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/ModelSelector.tsx` at line 1, Replace the rotated
ChevronLeft icon usage with the semantically correct ChevronRight to represent
the "More models" row; find the occurrence of ChevronLeft (used with a
rotate-180 CSS transform) in the ModelSelector component and swap it for
ChevronRight (imported from lucide-react) and remove the rotate-180
class/transform so the icon is not rotated manually.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@frontend/src/components/ModelSelector.tsx`:
- Around line 363-366: The dropdown's showAdvanced state (showAdvanced) isn't
reset when the menu closes, so add a close handler on the DropdownMenu (use its
onOpenChange or equivalent prop) that sets showAdvanced to false whenever open
becomes false; update the component to pass a function like onOpenChange={(open)
=> { if (!open) setShowAdvanced(false); }} to the DropdownMenu (the DropdownMenu
and showAdvanced state/setShowAdvanced variables identify where to make the
change).

---

Nitpick comments:
In `@frontend/src/components/ModelSelector.tsx`:
- Around line 391-393: isDisabled is inferred as boolean | undefined because
targetModelConfig?.disabled can be undefined; change its assignment in the
ModelSelector component so it always yields a boolean by normalizing the
optional value (e.g., use "|| false"). Specifically update the expression that
builds isDisabled from isDisabledDueToImages and targetModelConfig?.disabled
(and reference the isDisabledDueToImages variable and
targetModelConfig?.disabled) to coerce the latter to a boolean
(targetModelConfig?.disabled || false) so isDisabled is strictly boolean and
matches the advanced-list pattern.
- Line 1: Replace the rotated ChevronLeft icon usage with the semantically
correct ChevronRight to represent the "More models" row; find the occurrence of
ChevronLeft (used with a rotate-180 CSS transform) in the ModelSelector
component and swap it for ChevronRight (imported from lucide-react) and remove
the rotate-180 class/transform so the icon is not rotated manually.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7fefb93 and 7959cb4.

📒 Files selected for processing (1)
  • frontend/src/components/ModelSelector.tsx

@AnthonyRonning AnthonyRonning merged commit 313e3e7 into master Feb 24, 2026
18 checks passed
@AnthonyRonning AnthonyRonning deleted the model-selector-quick-powerful branch February 24, 2026 14:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Simplify Model Selector to Quick and Powerful

2 participants