Skip to content

feat(cowork): shown-but-disabled locked models for free tier (ENG-531)#327

Open
Dav1dF wants to merge 10 commits into
stagingfrom
david/eng-531-free-tier-locked-models
Open

feat(cowork): shown-but-disabled locked models for free tier (ENG-531)#327
Dav1dF wants to merge 10 commits into
stagingfrom
david/eng-531-free-tier-locked-models

Conversation

@Dav1dF

@Dav1dF Dav1dF commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

What

Implements the shown-but-disabled UX for free (Starter) users attempting a blocked frontier model, decided in ENG-525 and tracked in ENG-531.

Free users see frontier models (Opus, Gemini, OpenAI) in the model picker but shown disabled, with an "Upgrade to Pro" CTA. The free model — MindsHub Air (Kimi K2) — is selectable and surfaced at the top so the user doesn't scroll past locked models. Applied to both the composer model picker and the Settings › Agent model dropdown.

Before / After

Before and after — composer model picker

Left: today (all models selectable, no gating). Middle/right: free-tier treatment in light and dark — MindsHub Air on top, frontier models disabled with a single "Upgrade to Pro" CTA, contrast preserved in both themes.

How it works

  • lib/modelEntitlement.jsisFrontierModel / isModelLocked / orderUnlockedFirst. A server-provided locked flag always wins; until the StatSig tier gate exposes it (backend owned by Lucas / Max A.), an interim client heuristic locks frontier families on the free tier.
  • lib/useDevTier.js — a DEV-only tier simulation so the treatment is reviewable before the backend flag lands (__setModelTier('free'|'pro'|null) in DevTools). Dormant in production — nothing locks until a real tier/flag exists.
  • Composer — locked rows are inert (no hover/click/tooltip); one underlined "Upgrade to Pro" CTA in the header is the only redirect; header pinned with an internally-scrolling list.
  • Settings dropdown — disabled locked options + a single upgrade link; native <select> border matched to the text inputs.
  • Theme-aware --link-strong token so links and selection checks keep contrast in light (dark teal) and dark (lighter green).
  • latest:kimi → "MindsHub Air"; recommendedModelOptions forward-compat to pass a server locked flag through.
  • Home now surfaces the model picker (removed hideModel).

Backend dependency

The full gate depends on the tier-gated model list including locked frontier models with a per-model locked flag (not stripped). Lucas confirmed he'll make this change in the StatSig config; this client reads that flag as soon as it lands. Until then the treatment only activates via the DEV tier simulation.

Tests

First unit tests in the repo (vitest, node env, no jsdom): entitlement logic + label/options, 16 passing (npm test).

Notes

  • Production behavior today: with no tier flag, the picker shows all models selectable — the locked treatment is dormant until the flag/tier arrives.
  • docs/eng-531/before-after.png is a representative render for this PR; drop it before merge if the team prefers attachments over a committed asset.

Free (Starter) users see frontier models (Opus, Gemini, OpenAI) in the model
picker but shown disabled, with an "Upgrade to Pro" CTA; MindsHub Air (the free
model) is selectable and surfaced at the top. Applied to both the composer model
picker and the Settings > Agent model dropdown.

- modelEntitlement: isFrontierModel / isModelLocked / orderUnlockedFirst, with
  server `locked`-flag priority and an interim client heuristic until the StatSig
  gate exposes the flag (backend owned by Lucas).
- Shared DEV-only tier simulation (useDevTier) to review pre-flag; dormant in prod.
- Composer: inert locked rows, single "Upgrade to Pro" header CTA, pinned header
  with an internally-scrolling model list.
- Settings dropdown: disabled locked options + single upgrade link; select border
  matched to text inputs.
- Theme-aware --link-strong token so links/checks keep contrast in light and dark.
- Rename latest:kimi -> "MindsHub Air"; recommendedModelOptions forward-compat for
  the server locked flag.
- Surface the model picker on Home (remove hideModel).
- First unit tests in the repo (vitest): entitlement + label/options, 16 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
@Dav1dF
Dav1dF requested review from lucas-koontz and pnewsam July 2, 2026 05:52
Dav1dF and others added 2 commits July 1, 2026 23:33
Follow-up to the first-pass code review of PR #327.

Correctness:
- Free tier no longer runs a locked model by default: a guard falls the active
  selection back to the first unlocked model (MindsHub Air) when the selection
  is locked, covering the composer pill and every send path; the prepended
  selection also carries a computed `locked` flag. (finding 1)
- Settings routes its model list through recommendedModelOptions, so it handles
  both id-strings and the future {id, locked} object shape instead of breaking
  on it. (finding 2)
- Settings shows MindsHub Air as the effective selection when the saved role
  model is locked, rather than a disabled frontier model. (finding 3)
- Home model picker: documented as an intentional un-hide; fixed the stale
  hideModel comment. (finding 4)

Cleanups:
- Settings reuses orderUnlockedFirst and precomputes each model's lock once
  (removes the divergent inline sort and repeated isModelLocked calls). (5, 7)
- Shared UpgradeToProLink component replaces the duplicated upsell CTA. (6)
- effectiveTier() centralizes the "only minds-cloud is gated" rule. (8)
- Dropped dead `desc: ''` model field. (9)

Tests: +3 for effectiveTier (19 passing).

Co-Authored-By: Claude <noreply@anthropic.com>
- Normalize removeProvider's model fallback through recommendedModelOptions so
  it survives the future {id, locked} backend shape (same class as the earlier
  SettingsView fix).
- Finish removing the dead `desc` field: dropped from the remaining
  setSelectedModel constructions and the Composer render branch.
- Simplify savedLocked (removed the unreachable ternary else branch).
- Memoize modelOptions (was recomputing isModelLocked + reallocating on every
  render during live streams) and order the prepended selection so a transient
  locked selection can't render above the unlocked models.

Selection-guard effect was re-reviewed for loop/regression risk and cleared.

Co-Authored-By: Claude <noreply@anthropic.com>
@Dav1dF

Dav1dF commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Open decision for the second pass (Lucas / Paul)

Self-review is done (two passes; fixes pushed in 14e1fc1 and 8be9f14). One item is deliberately left open because it depends on backend behavior, not client code.

Settings shows MindsHub Air, but the saved override still points at the locked model. In SettingsView, when a free user's saved planning/coding model is a locked frontier model, the dropdown displays the first unlocked model (MindsHub Air) as the effective selection. That substitution is display only. It does not call writeOverride, so the persisted modelOverrides still names the locked model until the user actively picks a new one.

Whether the client should also persist the fallback depends on how the M1 entitlement service treats a persisted but locked model:

  • If the backend substitutes (routes a free user to Air regardless of the requested model), display only is correct and nothing more is needed here.
  • If the backend rejects (denies the locked model), the saved config will error, so the client should migrate the override to an allowed model.

Migrating on the client means calling writeOverride from an effect, which marks the settings form dirty on open. That is a product and UX call, so I have not made it unilaterally.

Everything else from both review passes is resolved. The composer runtime selection is already guarded (a locked active selection falls back to MindsHub Air), so a task sent from the composer always uses an allowed model regardless of this Settings decision.

Dav1dF and others added 2 commits July 2, 2026 07:25
Drop the 12px marginBottom on the Settings upgrade link — it only existed
in the section with a locked model (Planning), pushing its Reasoning effort
control lower than Coding's. Both now share the same 6px rhythm.

Co-Authored-By: Claude <noreply@anthropic.com>
Move the 12px above Reasoning effort onto the effort control itself so both
Planning and Coding get it, instead of on the (Planning-only) upgrade link.
Preserves the separation and aligns the two sections.

Co-Authored-By: Claude <noreply@anthropic.com>
@Dav1dF
Dav1dF marked this pull request as draft July 2, 2026 23:37
@Dav1dF
Dav1dF removed request for lucas-koontz and pnewsam July 2, 2026 23:37
Dav1dF and others added 2 commits July 2, 2026 17:34
Add a Planning | Coding segmented toggle to the home composer's model
menu so both agent roles are selectable from the composer, not only in
Settings. Each tab drives its own role and writes through to the global
agent config (planningModel / codingModel) via saveSettings, keeping
Settings > Agent in sync both ways. Planning continues to drive the
per-message model. Gated behind a new `roles` prop so ChatView and the
Projects quick-launch picker keep the single-model API.

Per-tab ENG-531 lock treatment: each role's list dims locked frontier
rows with one "Upgrade to Pro" CTA, unlocked models first.

Also stop hardcoding the free model's "MindsHub Air" label in the client
— recommendedModelOptions now prefers a config-provided label, so the
display name lives in the backend model config (source of truth) and
falls back to the id-derived label.

Per-task role overrides (vs global settings) tracked as ENG-554.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@Dav1dF
Dav1dF requested a review from MinuraPunchihewa July 3, 2026 00:58
@Dav1dF
Dav1dF marked this pull request as ready for review July 3, 2026 01:00
Pull the Planning/Coding model-list derivation out of App.jsx into a pure
lib/roleModels.js (settingKeyForRole, buildRoleModels, withSelectionFirst),
removing the planning/coding duplication and making the logic testable
without a React harness. App wires them into the same memos.

Adds roleModels.test.js (10 tests): role→settings-key mapping, tier-based
locking + unlocked-first ordering, config-label as display name, and the
selection-prepend/no-op/reorder cases. Suite now 30 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
@Dav1dF

Dav1dF commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Hi @minura, quick orientation since this PR grew past the original ENG-531 scope.

What it does now

  • Free users see locked frontier models with a lock and an "Upgrade to Pro" link (original ENG-531).
  • New: the composer model pill now has a Planning | Coding toggle, so both agent roles can be picked from the composer, not just from Settings.
  • Picking a model saves to the global agent config (planning_model / coding_model), the same values Settings > Agent uses. The per-message API only takes one model, so a true per-task override is deferred to ENG-554.
  • Removed the hardcoded "MindsHub Air" label. The name now comes from the model config. Until the backend serves a label the free model shows as latest:kimi. That is ENG-561 (assigned to you).

Where to look

  • lib/roleModels.js and lib/modelEntitlement.js: the pure logic, 30 unit tests.
  • components/Composer.jsx: the tabs and the locked-row rendering.
  • App.jsx: role state and save-through (handleRoleModelChange).

One thing to weigh in on
When the saved model is locked, Settings shows MindsHub Air as the effective pick but does not rewrite the saved value (display only). Persist that fallback, or leave it display only? Depends on whether the entitlement service substitutes or rejects a saved locked model. More detail in the pinned comment above.

Dav1dF and others added 2 commits July 2, 2026 18:29
Reconcile ENG-531 with #326 (per-model availability) + #328 (Settings
polish), both of which landed on staging and overlap the model picker.

- Adopt the server per-model availability signal (settings.modelEnabled)
  as the source of truth for locking, on BOTH surfaces. New helper
  isRoleModelLocked(model, tier, modelEnabled): server opinion wins both
  ways (false=locked, true=unlocked); the client tier heuristic is the
  fallback only when the server has no entry for that id (dev sim / direct
  providers). Wired through buildRoleModels / withSelectionFirst and the
  App selection guard.
- Keep our lock treatment + framing (per product decision): dimmed
  disabled rows + one "Upgrade to Pro" link, no per-option suffix, no
  "No credits" wording, on both the composer picker and Settings.
- Take staging's Settings polish (#328): provider key-input/status-pill
  layout, fieldLabel styling (applied to the effort label too, keeping our
  12px alignment fix). Keep staging's separate no-credits notice (402/429
  provider exhaustion, distinct from tier locking).

Conflict was confined to SettingsView.jsx; App.jsx + globals.css
auto-merged. Renderer build clean; 34 unit tests pass.
…iders

Two small cleanups to the Settings polish taken from #328:
- Drop the dotted underline on the agent-model field labels (Provider /
  Model / Reasoning effort). A dotted underline reads as a "hover for a
  tooltip" affordance, and none is wired; keep the plain bold label.
- Remove the vertical divider (borderLeft) between the label and control
  columns in every Section; the horizontal row separators are enough.

Co-Authored-By: Claude <noreply@anthropic.com>
@Dav1dF

Dav1dF commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

@torrmal heads-up: this PR (ENG-531) merges your Settings polish from #328 and adjusts two small visual details in it. Wanted to explain the reasoning and get your take.

1. Vertical dividers (the left border between the label column and the controls). Removed them. Each row already separates the label from its control through the two-column layout and spacing, and the rows are divided by the horizontal lines. The vertical rule repeats that same separation, so running down every row it reads as extra visual noise and competes with the horizontal rhythm. Dropping it keeps the structure just as clear with less clutter.

2. Dotted underline on the field labels (Provider, Model, Reasoning effort). Removed the underline, kept the bold label. A dotted underline is a common convention for "hover me for a definition," but nothing is wired to it here, so it signals an interaction that does not exist. Plain bold labels keep the hierarchy without the false cue.

Both are one-line changes and easy to revert. If you feel strongly about either, happy to put it back or find a middle ground.

@Dav1dF
Dav1dF requested a review from lucas-koontz July 3, 2026 01:56
@Dav1dF

Dav1dF commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

@lucas-koontz pulling you in as a reviewer since this PR now builds directly on your #326 modelEnabled work (it is the lock source on both the composer picker and Settings). Two things for you specifically:

1. Open decision (your call, depends on the entitlement service). In Settings, when the saved model is locked for the tier, we show the first unlocked model (MindsHub Air) as the effective selection but do not rewrite the persisted modelOverrides (display only). Should we persist that fallback, or leave it display only? It depends on whether the M1 entitlement service substitutes a permitted model for a saved locked one, or rejects it. If it substitutes, display only is correct. If it rejects, we should migrate the saved value on load.

2. One semantic to confirm. #326 treats modelEnabled[id] === false as locked (true or absent = available). We extended it slightly: an explicit true now overrides the client tier heuristic, so the server can force-unlock a model the client would otherwise gate. Absent still falls back to the heuristic. Does that match your intent for the flag?

@MinuraPunchihewa MinuraPunchihewa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@Dav1dF Overall, this looks fine to me.
I also created a PR on top of this to add the display labels: #343

I do have one question though; from what I can see, in the model pickers you have added the models listed are those that are supported by the provider of the planning and coding models that the user has currently chosen. In situations where a user has configured multiple models, is this still how we want it to be? Or do we want the models from all configured providers to be displayed?

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.

2 participants