From c5e6cd91a4417b54cddb7b7fbe837c238371e54f Mon Sep 17 00:00:00 2001 From: Zongwei9888 Date: Thu, 6 Aug 2026 19:57:44 +0800 Subject: [PATCH 1/3] fix(providers): resolve reasoning capabilities through the model catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported in #153 as "DeepSeek shows no thinking levels". The cause is wider than DeepSeek: infer_reasoning_capabilities() was a chain of substring checks kept apart from the catalog, and it had rotted. It recognised claude-sonnet-4-6 while the catalog had moved on to claude-sonnet-5, so on main today these all advertise no reasoning at all: claude-sonnet-5, claude-opus-4-8, claude-sonnet-4-5, claude-haiku-4-5, deepseek-r1, deepseek-reasoner, deepseek-chat, qwen3-max, grok-4 The Desktop effort picker renders reasoningOptions(model), so an empty capability means the control appears with nothing in it — exactly what #153 describes. The provider side was never the problem: deepseek already carries thinking_style="thinking_type", so the wire format was wired all along. Both catalog.py and model_compat.py open by naming `model_id.includes(...)` as the anti-pattern to avoid, and catalog.py already resolves every other per-model fact — context window, output cap, pricing — through a normalize → snapshot → seed → family → default cascade. Reasoning now joins it as a ModelInfo field, so one id yields one answer from one lookup. profiles.py had been calling resolve_model_info() and the name chain on adjacent lines for the same model. Consequences beyond the reported bug: * An unseen deepseek-r2 or gpt-5.9 inherits its family row instead of silently losing its controls. Shipping a model no longer needs a code edit. * Family fallback deliberately advertises less than an exact seed row: claude-sonnet-4- keeps the effort ladder but not summarised thinking, which it predates. Claiming it would route raw trace text through the summary channel. * Dotted aliases (claude-sonnet-4.6) are listed rather than normalised, because the dot is load-bearing elsewhere in the table — gpt-5.4 and kimi-k2.5 are distinct models, not separator noise. A ladder is only claimed where the vendor publishes named levels; the rest get the Auto/Off surface ModelReasoningCapabilities already documents for "reasoning exists, levels unpublished". GLM and MiniMax stay absent: they need verified context/output limits before they earn a seed row, and that is now a table edit rather than a branch. Co-Authored-By: Claude Opus 5 (1M context) --- core/providers/catalog.py | 200 ++++++++++++++++++---- core/providers/reasoning.py | 71 ++++---- tests/test_model_catalog_service.py | 5 + tests/test_reasoning_catalog_alignment.py | 122 +++++++++++++ 4 files changed, 328 insertions(+), 70 deletions(-) create mode 100644 tests/test_reasoning_catalog_alignment.py diff --git a/core/providers/catalog.py b/core/providers/catalog.py index a73efb43..932a1997 100644 --- a/core/providers/catalog.py +++ b/core/providers/catalog.py @@ -35,6 +35,8 @@ from pathlib import Path from typing import Any +from core.providers.reasoning import ModelReasoningCapabilities + @dataclass(frozen=True, slots=True) class ModelInfo: @@ -46,6 +48,12 @@ class ModelInfo: unpriced model). ``source`` records where the value came from — ``seed``, ``family:``, ``default``, or ``snapshot`` — so a surprising compaction budget is traceable to its origin. + + ``reasoning`` describes the thinking controls the model advertises, or + ``None`` for a model with no reasoning mode. It lives here rather than in + a separate resolver so that one model id yields one answer through one + cascade: a family rule that grants ``deepseek-r2`` the right context + window grants it the right effort levels in the same step. """ id: str @@ -54,7 +62,58 @@ class ModelInfo: input_cost_per_1m: float | None = None output_cost_per_1m: float | None = None source: str = "seed" + reasoning: ModelReasoningCapabilities | None = None + + +# -------------------------------------------------------------------------- +# Reasoning profiles shared by the seed rows below. +# +# Naming them keeps the table scannable and makes "these models expose the +# same controls" an explicit statement instead of repeated literals. A ladder +# is only claimed where the vendor publishes named effort levels; everything +# else gets the honest Auto/Off surface that ``ModelReasoningCapabilities`` +# documents for "reasoning exists, levels unpublished". +# -------------------------------------------------------------------------- +#: OpenAI ``reasoning_effort`` — gpt-5 family and the o-series. +_REASONING_OPENAI = ModelReasoningCapabilities( + supported_efforts=("minimal", "low", "medium", "high"), + default_effort="medium", + default_enabled=True, + supports_summary=True, +) +#: Anthropic extended thinking, mapped onto DeepCode's semantic ladder. +_REASONING_ANTHROPIC = ModelReasoningCapabilities( + supported_efforts=("low", "medium", "high", "max"), + default_effort="high", + default_enabled=True, + supports_summary=True, +) +#: Same ladder, but the model streams raw thinking blocks rather than the +#: summarised channel. Used for the family fallback: an unrecognised +#: ``claude-sonnet-4-`` predates summarisation, and claiming it would +#: route real trace text through the summary channel. +_REASONING_ANTHROPIC_TRACE = ModelReasoningCapabilities( + supported_efforts=_REASONING_ANTHROPIC.supported_efforts, + default_effort=_REASONING_ANTHROPIC.default_effort, + default_enabled=True, + supports_summary=False, +) +#: Moonshot Kimi K3 publishes three levels. +_REASONING_KIMI_K3 = ModelReasoningCapabilities( + supported_efforts=("low", "high", "max"), + default_effort="max", + default_enabled=True, + supports_summary=True, +) +#: A binary thinking switch with no published ladder (``thinking_style`` on +#: the ProviderSpec is the wire mechanism). Product surface: Auto / Off. +_REASONING_TOGGLE = ModelReasoningCapabilities(default_enabled=True) +#: Reasoning is intrinsic and cannot be switched off (DeepSeek R, Grok 4). +_REASONING_ALWAYS_ON = ModelReasoningCapabilities( + default_enabled=True, + mandatory=True, +) # -------------------------------------------------------------------------- # Seed catalog — exact ids DeepCode targets, curated from models.dev. @@ -66,44 +125,113 @@ class ModelInfo: # -------------------------------------------------------------------------- _SEED: dict[str, ModelInfo] = { # OpenAI GPT-5 family (400K context / 128K output). - "gpt-5": ModelInfo("gpt-5", 400_000, 128_000, 1.25, 10.0), - "gpt-5-mini": ModelInfo("gpt-5-mini", 400_000, 128_000, 0.25, 2.0), - "gpt-5-nano": ModelInfo("gpt-5-nano", 400_000, 128_000, 0.05, 0.40), - "gpt-5.1": ModelInfo("gpt-5.1", 400_000, 128_000, 1.25, 10.0), - "gpt-5.2": ModelInfo("gpt-5.2", 400_000, 128_000, 1.25, 10.0), - "gpt-5.4": ModelInfo("gpt-5.4", 400_000, 128_000, 1.25, 10.0), + "gpt-5": ModelInfo( + "gpt-5", 400_000, 128_000, 1.25, 10.0, reasoning=_REASONING_OPENAI + ), + "gpt-5-mini": ModelInfo( + "gpt-5-mini", 400_000, 128_000, 0.25, 2.0, reasoning=_REASONING_OPENAI + ), + "gpt-5-nano": ModelInfo( + "gpt-5-nano", 400_000, 128_000, 0.05, 0.40, reasoning=_REASONING_OPENAI + ), + "gpt-5.1": ModelInfo( + "gpt-5.1", 400_000, 128_000, 1.25, 10.0, reasoning=_REASONING_OPENAI + ), + "gpt-5.2": ModelInfo( + "gpt-5.2", 400_000, 128_000, 1.25, 10.0, reasoning=_REASONING_OPENAI + ), + "gpt-5.4": ModelInfo( + "gpt-5.4", 400_000, 128_000, 1.25, 10.0, reasoning=_REASONING_OPENAI + ), # OpenAI reasoning o-series (200K context / 100K output). - "o1": ModelInfo("o1", 200_000, 100_000, 15.0, 60.0), - "o3": ModelInfo("o3", 200_000, 100_000, 2.0, 8.0), - "o3-mini": ModelInfo("o3-mini", 200_000, 100_000, 1.1, 4.4), - "o4-mini": ModelInfo("o4-mini", 200_000, 100_000, 1.1, 4.4), + "o1": ModelInfo("o1", 200_000, 100_000, 15.0, 60.0, reasoning=_REASONING_OPENAI), + "o3": ModelInfo("o3", 200_000, 100_000, 2.0, 8.0, reasoning=_REASONING_OPENAI), + "o3-mini": ModelInfo( + "o3-mini", 200_000, 100_000, 1.1, 4.4, reasoning=_REASONING_OPENAI + ), + "o4-mini": ModelInfo( + "o4-mini", 200_000, 100_000, 1.1, 4.4, reasoning=_REASONING_OPENAI + ), # OpenAI GPT-4 family. "gpt-4o": ModelInfo("gpt-4o", 128_000, 16_384, 2.5, 10.0), "gpt-4o-mini": ModelInfo("gpt-4o-mini", 128_000, 16_384, 0.15, 0.60), "gpt-4.1": ModelInfo("gpt-4.1", 1_047_576, 32_768, 2.0, 8.0), # Anthropic Claude (200K context; output varies by tier). - "claude-opus-4-1": ModelInfo("claude-opus-4-1", 200_000, 32_000, 15.0, 75.0), - "claude-opus-4-8": ModelInfo("claude-opus-4-8", 200_000, 32_000, 15.0, 75.0), - "claude-sonnet-4-5": ModelInfo("claude-sonnet-4-5", 200_000, 64_000, 3.0, 15.0), - "claude-sonnet-5": ModelInfo("claude-sonnet-5", 200_000, 64_000, 3.0, 15.0), - "claude-haiku-4-5": ModelInfo("claude-haiku-4-5", 200_000, 64_000, 1.0, 5.0), + "claude-opus-4-1": ModelInfo( + "claude-opus-4-1", 200_000, 32_000, 15.0, 75.0, reasoning=_REASONING_ANTHROPIC + ), + "claude-opus-4-6": ModelInfo( + "claude-opus-4-6", 200_000, 32_000, 15.0, 75.0, reasoning=_REASONING_ANTHROPIC + ), + "claude-opus-4-8": ModelInfo( + "claude-opus-4-8", 200_000, 32_000, 15.0, 75.0, reasoning=_REASONING_ANTHROPIC + ), + "claude-sonnet-4-5": ModelInfo( + "claude-sonnet-4-5", 200_000, 64_000, 3.0, 15.0, reasoning=_REASONING_ANTHROPIC + ), + "claude-sonnet-4-6": ModelInfo( + "claude-sonnet-4-6", 200_000, 64_000, 3.0, 15.0, reasoning=_REASONING_ANTHROPIC + ), + "claude-sonnet-5": ModelInfo( + "claude-sonnet-5", 200_000, 64_000, 3.0, 15.0, reasoning=_REASONING_ANTHROPIC + ), + "claude-haiku-4-5": ModelInfo( + "claude-haiku-4-5", 200_000, 64_000, 1.0, 5.0, reasoning=_REASONING_ANTHROPIC + ), + # Dotted spellings of the same Anthropic releases. Listed rather than + # normalised away, because the dot is meaningful elsewhere in this table + # (``gpt-5.4``, ``kimi-k2.5`` are distinct models, not separator noise). + "claude-opus-4.6": ModelInfo( + "claude-opus-4.6", 200_000, 32_000, 15.0, 75.0, reasoning=_REASONING_ANTHROPIC + ), + "claude-sonnet-4.6": ModelInfo( + "claude-sonnet-4.6", 200_000, 64_000, 3.0, 15.0, reasoning=_REASONING_ANTHROPIC + ), + "claude-sonnet-4.5": ModelInfo( + "claude-sonnet-4.5", 200_000, 64_000, 3.0, 15.0, reasoning=_REASONING_ANTHROPIC + ), + "claude-haiku-4.5": ModelInfo( + "claude-haiku-4.5", 200_000, 64_000, 1.0, 5.0, reasoning=_REASONING_ANTHROPIC + ), # Google Gemini (very large windows). - "gemini-2.5-pro": ModelInfo("gemini-2.5-pro", 1_048_576, 65_536, 1.25, 10.0), - "gemini-2.5-flash": ModelInfo("gemini-2.5-flash", 1_048_576, 65_536, 0.30, 2.5), - "gemini-3-pro": ModelInfo("gemini-3-pro", 1_048_576, 65_536, 1.25, 10.0), + "gemini-2.5-pro": ModelInfo( + "gemini-2.5-pro", 1_048_576, 65_536, 1.25, 10.0, reasoning=_REASONING_TOGGLE + ), + "gemini-2.5-flash": ModelInfo( + "gemini-2.5-flash", 1_048_576, 65_536, 0.30, 2.5, reasoning=_REASONING_TOGGLE + ), + "gemini-3-pro": ModelInfo( + "gemini-3-pro", 1_048_576, 65_536, 1.25, 10.0, reasoning=_REASONING_TOGGLE + ), # Moonshot Kimi. "kimi-k2": ModelInfo("kimi-k2", 256_000, 128_000, 0.60, 2.5), - "kimi-k2.5": ModelInfo("kimi-k2.5", 256_000, 128_000, 0.60, 2.5), - "kimi-k2.6": ModelInfo("kimi-k2.6", 256_000, 128_000, 0.60, 2.5), - "kimi-k3": ModelInfo("kimi-k3", 1_048_576, 128_000, 3.0, 15.0), + "kimi-k2.5": ModelInfo( + "kimi-k2.5", 256_000, 128_000, 0.60, 2.5, reasoning=_REASONING_TOGGLE + ), + "kimi-k2.6": ModelInfo( + "kimi-k2.6", 256_000, 128_000, 0.60, 2.5, reasoning=_REASONING_TOGGLE + ), + "kimi-k3": ModelInfo( + "kimi-k3", 1_048_576, 128_000, 3.0, 15.0, reasoning=_REASONING_KIMI_K3 + ), # DeepSeek. - "deepseek-v3": ModelInfo("deepseek-v3", 128_000, 8_192, 0.27, 1.10), - "deepseek-r1": ModelInfo("deepseek-r1", 128_000, 65_536, 0.55, 2.19), + "deepseek-v3": ModelInfo( + "deepseek-v3", 128_000, 8_192, 0.27, 1.10, reasoning=_REASONING_TOGGLE + ), + "deepseek-r1": ModelInfo( + "deepseek-r1", 128_000, 65_536, 0.55, 2.19, reasoning=_REASONING_ALWAYS_ON + ), # Alibaba Qwen. - "qwen3-max": ModelInfo("qwen3-max", 256_000, 32_768, 1.2, 6.0), - "qwen3-coder": ModelInfo("qwen3-coder", 256_000, 65_536, 1.0, 5.0), + "qwen3-max": ModelInfo( + "qwen3-max", 256_000, 32_768, 1.2, 6.0, reasoning=_REASONING_TOGGLE + ), + "qwen3-coder": ModelInfo( + "qwen3-coder", 256_000, 65_536, 1.0, 5.0, reasoning=_REASONING_TOGGLE + ), # xAI Grok. - "grok-4": ModelInfo("grok-4", 256_000, 64_000, 3.0, 15.0), + "grok-4": ModelInfo( + "grok-4", 256_000, 64_000, 3.0, 15.0, reasoning=_REASONING_ALWAYS_ON + ), } @@ -118,10 +246,22 @@ class ModelInfo: ("o1", _SEED["o1"]), ("o3", _SEED["o3"]), ("o4", _SEED["o4-mini"]), - ("claude-opus", _SEED["claude-opus-4-8"]), - ("claude-sonnet", _SEED["claude-sonnet-5"]), - ("claude-haiku", _SEED["claude-haiku-4-5"]), - ("claude", _SEED["claude-sonnet-5"]), + # Limits carry over from the newest tier member, but summarised thinking + # does not: a dated id like ``claude-sonnet-4-20250514`` predates it and + # streams raw trace. Exact seed rows above keep the full capability. + ( + "claude-opus", + replace(_SEED["claude-opus-4-8"], reasoning=_REASONING_ANTHROPIC_TRACE), + ), + ( + "claude-sonnet", + replace(_SEED["claude-sonnet-5"], reasoning=_REASONING_ANTHROPIC_TRACE), + ), + ( + "claude-haiku", + replace(_SEED["claude-haiku-4-5"], reasoning=_REASONING_ANTHROPIC_TRACE), + ), + ("claude", replace(_SEED["claude-sonnet-5"], reasoning=_REASONING_ANTHROPIC_TRACE)), ("gemini-3", _SEED["gemini-3-pro"]), ("gemini", _SEED["gemini-2.5-pro"]), ("kimi-k3", _SEED["kimi-k3"]), diff --git a/core/providers/reasoning.py b/core/providers/reasoning.py index 8854ec56..c7ad114c 100644 --- a/core/providers/reasoning.py +++ b/core/providers/reasoning.py @@ -134,55 +134,46 @@ def resolve_reasoning_effort( ) +#: Parameter names a remote catalog uses to advertise a reasoning control. +_REASONING_PARAMETERS = frozenset( + {"reasoning", "reasoning_effort", "include_reasoning", "thinking"} +) + + def infer_reasoning_capabilities( model_id: str, *, provider_name: str | None = None, supported_parameters: Iterable[str] = (), ) -> ModelReasoningCapabilities | None: - """Conservative offline fallback when a catalog has no structured data. - - This is intentionally centralized. Remote provider metadata wins; these - families only keep manual/custom catalogs usable without scattering - ``model_id`` checks through product code. + """Resolve the reasoning controls a model advertises, offline. + + Two ordered sources, no model-name conditionals: + + 1. The model catalog (:mod:`core.providers.catalog`), whose + normalize → snapshot → seed → family cascade already answers "what is + this model" for context window and pricing. An unseen ``deepseek-r2`` + inherits the ``deepseek-r`` family row here exactly as it does there. + 2. ``supported_parameters`` published by a remote catalog, which proves a + reasoning control exists even when the model is absent from the seed. + No named levels come with it, so the surface is Auto/Off. + + Returns ``None`` when neither source knows of a reasoning mode. Callers + treat that as "no thinking controls", so guessing here would put dead + options in the product; a missing model belongs in the seed table, not in + a branch added to this function. """ - model = model_id.strip().lower() - provider = (provider_name or "").strip().lower() - parameters = {str(item).strip().lower() for item in supported_parameters} + # Imported lazily: the catalog imports this module for ``ModelInfo``. + from core.providers.catalog import resolve_model_info + + known = resolve_model_info(model_id).reasoning + if known is not None: + return known - if "kimi-k3" in model: - return ModelReasoningCapabilities( - supported_efforts=("low", "high", "max"), - default_effort="max", - default_enabled=True, - supports_summary=True, - ) - if model.startswith(("o1", "o3", "o4")) or "gpt-5" in model: - return ModelReasoningCapabilities( - supported_efforts=("minimal", "low", "medium", "high"), - default_effort="medium", - default_enabled=True, - supports_summary=True, - ) - if "claude-" in model and any( - family in model - for family in ( - "opus-4-6", - "opus-4.6", - "sonnet-4-6", - "sonnet-4.6", - ) - ): - return ModelReasoningCapabilities( - supported_efforts=("low", "medium", "high", "max"), - default_effort="high", - default_enabled=True, - supports_summary=True, - ) - if parameters.intersection( - {"reasoning", "reasoning_effort", "include_reasoning", "thinking"} - ): + parameters = {str(item).strip().lower() for item in supported_parameters} + if parameters & _REASONING_PARAMETERS: + provider = (provider_name or "").strip().lower() return ModelReasoningCapabilities(supports_summary=provider != "anthropic") return None diff --git a/tests/test_model_catalog_service.py b/tests/test_model_catalog_service.py index ecd72351..4f8c860d 100644 --- a/tests/test_model_catalog_service.py +++ b/tests/test_model_catalog_service.py @@ -10,6 +10,7 @@ from core.providers.catalog_service import CatalogModel, ModelCatalogService from core.providers.credentials import CredentialStore from core.providers.profiles import ConnectionResolver +from core.providers.reasoning import ModelReasoningCapabilities def _connection( @@ -51,6 +52,10 @@ def test_remote_catalog_is_cached_and_refresh_falls_back_to_last_known_good( context_window=262_144, max_output_tokens=65_536, supported_parameters=("tools",), + # Stated explicitly so the round-trip below compares like with + # like: rehydration infers capabilities only when the payload + # carries none, and this test is about caching, not inference. + reasoning=ModelReasoningCapabilities(default_enabled=True), ), ) monkeypatch.setattr(service, "_fetch", lambda _connection: discovered) diff --git a/tests/test_reasoning_catalog_alignment.py b/tests/test_reasoning_catalog_alignment.py new file mode 100644 index 00000000..2e15642f --- /dev/null +++ b/tests/test_reasoning_catalog_alignment.py @@ -0,0 +1,122 @@ +"""Reasoning capabilities resolve through the model catalog, not a name chain. + +The previous resolver was a chain of ``if "kimi-k3" in model`` checks living +apart from the catalog. It rotted exactly as its own docstring predicted: it +recognised ``claude-sonnet-4-6`` while the catalog had moved on to +``claude-sonnet-5``, so the current Anthropic and DeepSeek line-ups advertised +no thinking controls at all and the Desktop effort picker rendered empty. + +These tests pin the properties that failure violated. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from core.providers.catalog import _SEED, resolve_model_info # noqa: E402 +from core.providers.reasoning import infer_reasoning_capabilities # noqa: E402 + + +@pytest.mark.parametrize( + "model_id", + [ + "deepseek-reasoner", + "deepseek-r1", + "deepseek-chat", + "deepseek/deepseek-v3", + "claude-sonnet-5", + "claude-opus-4-8", + "claude-sonnet-4-5", + "claude-haiku-4-5", + "gpt-5.4", + "o3", + "kimi-k3", + "qwen3-max", + "grok-4", + ], +) +def test_shipping_models_advertise_reasoning(model_id: str) -> None: + """Every reasoning-capable model DeepCode seeds must say so. + + Each id here returned ``None`` before the resolver moved into the catalog, + which is what left the effort picker empty. + """ + + assert infer_reasoning_capabilities(model_id) is not None + + +@pytest.mark.parametrize( + "model_id", + ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "kimi-k2"], +) +def test_non_reasoning_models_stay_silent(model_id: str) -> None: + """The fix must not hand thinking controls to models without them.""" + + assert infer_reasoning_capabilities(model_id) is None + + +@pytest.mark.parametrize( + ("unseen", "family_member"), + [ + ("deepseek-r2", "deepseek-r1"), + ("gpt-5.9", "gpt-5"), + ("kimi-k3-turbo", "kimi-k3"), + ], +) +def test_unseen_models_inherit_their_family(unseen: str, family_member: str) -> None: + """A model the seed has never seen resolves through its family rule. + + This is the property a name chain cannot offer: releasing ``deepseek-r2`` + should not require editing code for it to keep its thinking controls. + """ + + inherited = infer_reasoning_capabilities(unseen) + assert inherited is not None + assert inherited == infer_reasoning_capabilities(family_member) + + +def test_family_fallback_does_not_claim_summarised_thinking() -> None: + """A dated Claude 4 id predates summarisation and must not claim it. + + ``claude-sonnet-4-20250514`` streams raw thinking blocks. Routing those + through the summary channel would surface private trace text as a summary, + so the family fallback deliberately advertises less than its seed row. + """ + + dated = infer_reasoning_capabilities("claude-sonnet-4-20250514") + seeded = infer_reasoning_capabilities("claude-sonnet-5") + + assert dated is not None and seeded is not None + assert dated.supported_efforts == seeded.supported_efforts + assert dated.supports_summary is False + assert seeded.supports_summary is True + + +def test_remote_parameters_still_win_for_unseeded_models() -> None: + """A model absent from the catalog is believed when its catalog says so. + + No named levels come with that signal, so the surface stays Auto/Off + rather than inventing effort names the provider never published. + """ + + capabilities = infer_reasoning_capabilities( + "some-vendor/brand-new-thinker", + supported_parameters=("tools", "reasoning_effort"), + ) + assert capabilities is not None + assert capabilities.supported_efforts == () + + +def test_capabilities_travel_with_the_rest_of_the_model_row() -> None: + """One id, one lookup: limits and thinking come from the same cascade.""" + + info = resolve_model_info("deepseek-r1") + assert info.context_window == _SEED["deepseek-r1"].context_window + assert info.reasoning is infer_reasoning_capabilities("deepseek-r1") From d7d426266c2cc02e5a31cd9971a299325ef5bdf4 Mon Sep 17 00:00:00 2001 From: Zongwei9888 Date: Thu, 6 Aug 2026 20:25:50 +0800 Subject: [PATCH 2/3] feat(providers): seed GLM and MiniMax, and declare Zhipu's thinking body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the catalog move, closing the two real gaps it exposed. Both lines were resolving to the 128K default with no reasoning at all — a 200K model trimmed to 128K, and a thinking-capable one advertising nothing. Limits taken from the vendors, not inferred: * GLM-4.6 — 200K in / 128K out (docs.z.ai/guides/llm/glm-4.6) * MiniMax M2 line — 204,800 total; M3 is the 1M tier (platform.minimax.io/docs/api-reference/api-overview). The published figure is input+output combined, which is how this table already reads it. Family rules follow, longest prefix first so ``minimax-m3`` keeps its 1M window while the rest of the line takes 200K. ``glm-4.7`` and ``glm-5.2`` inherit rather than falling off the table. Separately: Zhipu enables reasoning with ``thinking: {"type": "enabled"}``, the same body DeepSeek takes and the shape ``_THINKING_STYLE_BUILDERS["thinking_type"]`` already builds. The spec never declared it, so every GLM request went out with reasoning silently omitted whatever effort the user chose. One field; the mechanism was already there. MiniMax stays on the Auto/Off surface — it publishes no named effort ladder, and inventing one would put dead options in the picker. Co-Authored-By: Claude Opus 5 (1M context) --- core/providers/catalog.py | 21 +++++++++ core/providers/registry.py | 2 + tests/test_reasoning_catalog_alignment.py | 36 +++++++++++++++ tests/test_zhipu_thinking_wire.py | 54 +++++++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 tests/test_zhipu_thinking_wire.py diff --git a/core/providers/catalog.py b/core/providers/catalog.py index 932a1997..1336cb18 100644 --- a/core/providers/catalog.py +++ b/core/providers/catalog.py @@ -228,6 +228,23 @@ class ModelInfo: "qwen3-coder": ModelInfo( "qwen3-coder", 256_000, 65_536, 1.0, 5.0, reasoning=_REASONING_TOGGLE ), + # Zhipu GLM — 200K context / 128K output, thinking toggled with + # ``thinking: {"type": ...}`` (docs.z.ai/guides/llm/glm-4.6). + "glm-4.6": ModelInfo( + "glm-4.6", 204_800, 131_072, 0.43, 1.75, reasoning=_REASONING_TOGGLE + ), + # MiniMax — the M2 line shares one window; M3 is the long-context tier + # (platform.minimax.io/docs/api-reference/api-overview). The published + # limit is the input+output total. + "minimax-m2": ModelInfo( + "minimax-m2", 204_800, 131_072, 0.30, 1.20, reasoning=_REASONING_TOGGLE + ), + "minimax-m2.7": ModelInfo( + "minimax-m2.7", 204_800, 131_072, 0.30, 1.20, reasoning=_REASONING_TOGGLE + ), + "minimax-m3": ModelInfo( + "minimax-m3", 1_000_000, 131_072, 0.30, 1.20, reasoning=_REASONING_TOGGLE + ), # xAI Grok. "grok-4": ModelInfo( "grok-4", 256_000, 64_000, 3.0, 15.0, reasoning=_REASONING_ALWAYS_ON @@ -271,6 +288,10 @@ class ModelInfo: ("deepseek", _SEED["deepseek-v3"]), ("qwen", _SEED["qwen3-max"]), ("grok", _SEED["grok-4"]), + # Longest prefix first: M3 is the 1M tier, the rest of the line is 200K. + ("minimax-m3", _SEED["minimax-m3"]), + ("minimax", _SEED["minimax-m2.7"]), + ("glm", _SEED["glm-4.6"]), ) # Conservative fallback for a genuinely unknown model: 128K is the smallest diff --git a/core/providers/registry.py b/core/providers/registry.py index cd8232f4..a3ac3ae9 100644 --- a/core/providers/registry.py +++ b/core/providers/registry.py @@ -148,6 +148,8 @@ def label(self) -> str: backend="openai_compat", env_extras=(("ZHIPUAI_API_KEY", "{api_key}"),), default_api_base="https://open.bigmodel.cn/api/paas/v4", + # GLM takes the same ``thinking: {"type": ...}`` body as DeepSeek. + thinking_style="thinking_type", ), ProviderSpec( name="dashscope", diff --git a/tests/test_reasoning_catalog_alignment.py b/tests/test_reasoning_catalog_alignment.py index 2e15642f..040d18cf 100644 --- a/tests/test_reasoning_catalog_alignment.py +++ b/tests/test_reasoning_catalog_alignment.py @@ -120,3 +120,39 @@ def test_capabilities_travel_with_the_rest_of_the_model_row() -> None: info = resolve_model_info("deepseek-r1") assert info.context_window == _SEED["deepseek-r1"].context_window assert info.reasoning is infer_reasoning_capabilities("deepseek-r1") + + +@pytest.mark.parametrize( + ("model_id", "context_window"), + [ + # docs.z.ai/guides/llm/glm-4.6 — 200K in, 128K out. + ("glm-4.6", 204_800), + ("zai/glm-4.6", 204_800), + ("glm-4.7", 204_800), + # platform.minimax.io — the M2 line shares one window, M3 is the + # long-context tier, and the published figure is input+output. + ("MiniMax-M2", 204_800), + ("MiniMax-M2.5", 204_800), + ("MiniMax-M3", 1_000_000), + ], +) +def test_zhipu_and_minimax_resolve_off_the_default( + model_id: str, context_window: int +) -> None: + """Both lines used to land on the 128K default with no thinking at all. + + That is the failure mode the default is designed to avoid: a 200K model + trimmed to 128K, and a thinking-capable one advertising nothing. + """ + + info = resolve_model_info(model_id) + assert info.source != "default" + assert info.context_window == context_window + assert info.reasoning is not None + + +def test_minimax_long_context_tier_beats_the_general_prefix() -> None: + """Rule order matters: ``minimax-m3`` must win over ``minimax``.""" + + assert resolve_model_info("minimax-m3-preview").context_window == 1_000_000 + assert resolve_model_info("minimax-m2.1").context_window == 204_800 diff --git a/tests/test_zhipu_thinking_wire.py b/tests/test_zhipu_thinking_wire.py new file mode 100644 index 00000000..3cd592d7 --- /dev/null +++ b/tests/test_zhipu_thinking_wire.py @@ -0,0 +1,54 @@ +"""Zhipu declares the thinking body shape its API actually expects. + +GLM enables reasoning with ``thinking: {"type": "enabled"}`` +(docs.z.ai/guides/llm/glm-4.6) — the same body DeepSeek takes, and the shape +``_THINKING_STYLE_BUILDERS["thinking_type"]`` already builds. The provider +spec simply never declared it, so every GLM request went out with reasoning +silently omitted no matter what effort the user picked. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from core.providers.model_compat import resolve_model_compat # noqa: E402 +from core.providers.registry import find_by_name # noqa: E402 + + +@pytest.mark.parametrize( + ("effort", "expected"), + [("high", "enabled"), ("low", "enabled"), ("none", "disabled")], +) +def test_glm_requests_carry_a_thinking_body(effort: str, expected: str) -> None: + compat = resolve_model_compat( + model_name="glm-4.6", + spec=find_by_name("zhipu"), + reasoning_effort=effort, + ) + assert compat.thinking_extra_body == {"thinking": {"type": expected}} + + +@pytest.mark.parametrize("effort", [None, "auto"]) +def test_auto_leaves_the_choice_to_the_model(effort: str | None) -> None: + """``auto`` must not pin the switch either way — that is its whole point.""" + + compat = resolve_model_compat( + model_name="glm-4.6", + spec=find_by_name("zhipu"), + reasoning_effort=effort, + ) + assert compat.thinking_extra_body is None + + +def test_zhipu_matches_the_deepseek_wire_shape() -> None: + """Both vendors take the same body; the specs should agree on it.""" + + assert find_by_name("zhipu").thinking_style == "thinking_type" + assert find_by_name("deepseek").thinking_style == "thinking_type" From 696aa65499f98e663a51e7a4daa93bb46674f361 Mon Sep 17 00:00:00 2001 From: Zongwei9888 Date: Thu, 6 Aug 2026 21:06:08 +0800 Subject: [PATCH 3/3] feat(desktop): appearance preferences for width, theme and typography MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the display half of #151, #152 and #155. They are one problem wearing three hats — a value the user picks, persisted locally, surfaced to CSS — so they share one mechanism rather than three copies of read/validate/persist. src/app/appearance.ts holds the settings as a table. Each row owns its label, its range, its sanitiser and the custom property it feeds; the machinery around it is generic. Adding a preference is a row, and the settings UI renders from the same table instead of growing another block of JSX. Notes on the three: * Width (#151) — .conversation, its composer and the goal rail each hardcoded `min(820px, 100%)`. They now read one `--conversation-width`, which is the point: three copies of a layout constant cannot stay in step by hand. 100% restores the built-in cap rather than stretching edge to edge, so the default is byte-identical to today. * Theme (#152) — the report says there is no dark theme. There is: tokens.css has carried a full dark palette behind `prefers-color-scheme` all along. What was missing is overriding the OS, which is what this adds. CSS cannot share a declaration block between a media query and a selector, so the dark palette is now written twice; styles/tokens.test.ts fails if the copies ever disagree, because two tables drifting apart is the exact bug this project just spent a day fixing in its model catalog. VS Code theme import is not here — mapping a foreign schema onto these tokens is its own piece of work. * Typography (#155) — a free-text family list rather than a dropdown. A curated menu would be a hardcoded guess about fonts the user's machine may not have; the value is a prefix of the built-in stack, so anything missing falls through instead of leaving the UI unstyled. Size is a slider. Preferences apply immediately and live in localStorage: per-machine display choices, not project configuration. The store is read through useSyncExternalStore so the shell and the settings page share one copy — per-component state would let them disagree, and threading a controller down through the workspace would be prop drilling for something neither server state nor scoped to a subtree. Co-Authored-By: Claude Opus 5 (1M context) --- desktop/src/App.tsx | 3 + desktop/src/app/appearance.test.ts | 112 +++++++++++ desktop/src/app/appearance.ts | 182 ++++++++++++++++++ desktop/src/app/useAppearance.ts | 84 ++++++++ .../features/execution/Composer.module.css | 2 +- desktop/src/features/goal/GoalRail.module.css | 2 +- .../features/settings/AppearanceSettings.tsx | 138 +++++++++++++ .../src/features/settings/SettingsPage.tsx | 3 + .../thread/ThreadConversation.module.css | 2 +- desktop/src/styles/tokens.css | 69 ++++++- desktop/src/styles/tokens.test.ts | 92 +++++++++ 11 files changed, 683 insertions(+), 6 deletions(-) create mode 100644 desktop/src/app/appearance.test.ts create mode 100644 desktop/src/app/appearance.ts create mode 100644 desktop/src/app/useAppearance.ts create mode 100644 desktop/src/features/settings/AppearanceSettings.tsx create mode 100644 desktop/src/styles/tokens.test.ts diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index d4cf8daa..b04e7038 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -3,6 +3,7 @@ import { lazy, Suspense, useState } from "react"; import { projectCanExecute } from "./app/projectPresentation"; import { latestExecutingTurn } from "./app/interactiveTurnRouter"; +import { useAppearance } from "./app/useAppearance"; import { useDesktopUi } from "./app/useDesktopUi"; import { useComposerCommands } from "./app/useComposerCommands"; import { useWorkspaceController } from "./app/useWorkspaceController"; @@ -59,6 +60,8 @@ function LoadingSurface({ export function App({ runtime = tauriRuntime }: { runtime?: DesktopRuntime }) { const controller = useWorkspaceController(runtime); + // Mounted for its effect: paints saved appearance preferences at startup. + useAppearance(); const ui = useDesktopUi(); const transcript = useTranscriptMode(); const runComposerCommand = useComposerCommands(controller, ui); diff --git a/desktop/src/app/appearance.test.ts b/desktop/src/app/appearance.test.ts new file mode 100644 index 00000000..47d281e1 --- /dev/null +++ b/desktop/src/app/appearance.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + APPEARANCE_DEFAULTS, + APPEARANCE_SETTINGS, + applyAppearance, + readAppearance, + sanitizeAppearance, + writeAppearance, +} from "./appearance"; + +function root(): HTMLElement { + return document.documentElement; +} + +beforeEach(() => { + localStorage.clear(); + root().removeAttribute("style"); + root().removeAttribute("data-theme"); +}); + +describe("sanitizeAppearance", () => { + it("fills every setting from an empty blob", () => { + expect(sanitizeAppearance({})).toEqual(APPEARANCE_DEFAULTS); + }); + + it("clamps numbers into their declared range instead of rejecting them", () => { + const state = sanitizeAppearance({ conversationWidth: 5000, fontSize: -3 }); + expect(state.conversationWidth).toBe(100); + expect(state.fontSize).toBe(11); + }); + + it("survives values of the wrong type", () => { + const state = sanitizeAppearance({ + conversationWidth: "not a number", + theme: "chartreuse", + fontFamily: 42, + }); + expect(state).toEqual(APPEARANCE_DEFAULTS); + }); + + it("is total over the settings table", () => { + // A row added without a matching default would silently produce + // `undefined` here rather than failing at the point of the mistake. + const state = sanitizeAppearance({}); + for (const setting of APPEARANCE_SETTINGS) { + expect(state[setting.key]).toBeDefined(); + } + }); +}); + +describe("persistence", () => { + it("round-trips through storage", () => { + writeAppearance({ ...APPEARANCE_DEFAULTS, fontSize: 18, theme: "dark" }); + const restored = readAppearance(); + expect(restored.fontSize).toBe(18); + expect(restored.theme).toBe("dark"); + }); + + it("falls back to defaults when storage holds garbage", () => { + localStorage.setItem("deepcode.desktop.appearance.v1", "{not json"); + expect(readAppearance()).toEqual(APPEARANCE_DEFAULTS); + }); +}); + +describe("applyAppearance", () => { + it("writes nothing while every setting is at its default", () => { + applyAppearance(APPEARANCE_DEFAULTS, root()); + // Defaults live in the stylesheet. Echoing them as inline properties + // would shadow any future change to tokens.css. + expect(root().getAttribute("style")).toBeFalsy(); + expect(root().hasAttribute("data-theme")).toBe(false); + }); + + it("exposes a narrowed conversation as a percentage", () => { + applyAppearance({ ...APPEARANCE_DEFAULTS, conversationWidth: 70 }, root()); + expect(root().style.getPropertyValue("--conversation-width")).toBe("70%"); + }); + + it("keeps the built-in cap at full width", () => { + applyAppearance({ ...APPEARANCE_DEFAULTS, conversationWidth: 100 }, root()); + expect(root().style.getPropertyValue("--conversation-width")).toBe(""); + }); + + it("pins the theme with an attribute a stylesheet can select on", () => { + applyAppearance({ ...APPEARANCE_DEFAULTS, theme: "dark" }, root()); + expect(root().getAttribute("data-theme")).toBe("dark"); + + applyAppearance({ ...APPEARANCE_DEFAULTS, theme: "system" }, root()); + expect(root().hasAttribute("data-theme")).toBe(false); + }); + + it("appends preferred fonts as a prefix of the built-in stack", () => { + applyAppearance( + { ...APPEARANCE_DEFAULTS, fontFamily: "Sarasa Mono SC, Inter" }, + root(), + ); + // The trailing comma is what lets the default families follow, so an + // unavailable font degrades instead of leaving the UI unstyled. + expect(root().style.getPropertyValue("--font-ui-preferred")).toBe( + "Sarasa Mono SC, Inter,", + ); + }); + + it("clears a preference when it returns to its default", () => { + applyAppearance({ ...APPEARANCE_DEFAULTS, fontSize: 20 }, root()); + expect(root().style.getPropertyValue("--font-size-base")).toBe("20px"); + + applyAppearance(APPEARANCE_DEFAULTS, root()); + expect(root().style.getPropertyValue("--font-size-base")).toBe(""); + }); +}); diff --git a/desktop/src/app/appearance.ts b/desktop/src/app/appearance.ts new file mode 100644 index 00000000..d3ec937e --- /dev/null +++ b/desktop/src/app/appearance.ts @@ -0,0 +1,182 @@ +/** + * Appearance preferences: one declarative table, one apply path. + * + * Conversation width (#151), theme (#152) and typography (#155) are the same + * shape of problem — a value the user picks, persisted locally, surfaced to + * CSS. Writing three hooks would mean three copies of read/validate/persist + * and three chances for them to drift, so each setting is a row in + * `APPEARANCE_SETTINGS` and the machinery below is shared. + * + * Values reach the UI as custom properties on `:root`. Nothing imports this + * module to read a preference mid-render: stylesheets consume the variables, + * which keeps components free of appearance conditionals. + */ + +const STORAGE_KEY = "deepcode.desktop.appearance.v1"; + +/** Follow the OS, or pin one scheme regardless of it. */ +export type ThemePreference = "system" | "light" | "dark"; + +export const THEME_PREFERENCES: readonly ThemePreference[] = [ + "system", + "light", + "dark", +]; + +export interface AppearanceState { + /** Percentage of the available width the conversation column occupies. */ + conversationWidth: number; + theme: ThemePreference; + /** Base UI font size in px; everything else is relative to it. */ + fontSize: number; + /** + * Extra families tried before the built-in stack. Free text rather than a + * curated dropdown: the fonts a user actually has are theirs to know, and a + * fixed list would be both wrong and stale. Empty means "use the default". + */ + fontFamily: string; +} + +export const APPEARANCE_DEFAULTS: AppearanceState = { + conversationWidth: 100, + theme: "system", + fontSize: 14, + fontFamily: "", +}; + +/** + * How one preference is validated and handed to CSS. + * + * `cssVariable` is `null` for settings applied some other way — the theme is + * a `data-theme` attribute, because a stylesheet cannot switch on a variable. + */ +interface AppearanceSetting { + key: K; + label: string; + description: string; + cssVariable: string | null; + /** Coerce anything (stale storage, a bad edit) into a usable value. */ + sanitize(value: unknown): AppearanceState[K]; + /** Render the stored value as the CSS custom property's value. */ + toCss?(value: AppearanceState[K]): string; + /** Present for numeric settings so the UI can build a slider. */ + range?: { min: number; max: number; step: number; unit: string }; +} + +function clampNumber(value: unknown, min: number, max: number, fallback: number) { + const parsed = typeof value === "string" ? Number(value) : value; + if (typeof parsed !== "number" || !Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, Math.round(parsed))); +} + +const CONVERSATION_WIDTH: AppearanceSetting<"conversationWidth"> = { + key: "conversationWidth", + label: "Conversation width", + description: + "How much of the window the conversation column fills. The default keeps " + + "lines short for readability; widen it to use more of a large display.", + cssVariable: "--conversation-width", + range: { min: 40, max: 100, step: 5, unit: "%" }, + sanitize: (value) => + clampNumber(value, 40, 100, APPEARANCE_DEFAULTS.conversationWidth), + // 100% restores the built-in cap rather than stretching edge to edge, so + // the default stays exactly what it was before this setting existed. + toCss: (value) => (value >= 100 ? "min(820px, 100%)" : `${value}%`), +}; + +const THEME: AppearanceSetting<"theme"> = { + key: "theme", + label: "Theme", + description: + "Light and dark are both built in. 'System' follows the OS setting; the " + + "other two override it.", + cssVariable: null, + sanitize: (value) => + THEME_PREFERENCES.includes(value as ThemePreference) + ? (value as ThemePreference) + : APPEARANCE_DEFAULTS.theme, +}; + +const FONT_SIZE: AppearanceSetting<"fontSize"> = { + key: "fontSize", + label: "Font size", + description: "Base interface text size. Other sizes scale with it.", + cssVariable: "--font-size-base", + range: { min: 11, max: 22, step: 1, unit: "px" }, + sanitize: (value) => clampNumber(value, 11, 22, APPEARANCE_DEFAULTS.fontSize), + toCss: (value) => `${value}px`, +}; + +const FONT_FAMILY: AppearanceSetting<"fontFamily"> = { + key: "fontFamily", + label: "Preferred fonts", + description: + "Comma-separated families tried before the built-in stack — useful for " + + "picking a CJK face so mixed text renders consistently. Anything the " + + "system lacks is skipped, so listing several is safe.", + cssVariable: "--font-ui-preferred", + sanitize: (value) => (typeof value === "string" ? value.trim().slice(0, 200) : ""), + // Trailing comma: this list is a prefix of the built-in stack, never the + // whole of it, so an unavailable font can always fall through. + toCss: (value) => (value ? `${value},` : ""), +}; + +export const APPEARANCE_SETTINGS = [ + CONVERSATION_WIDTH, + THEME, + FONT_SIZE, + FONT_FAMILY, +] as const; + +/** Coerce a parsed blob of unknown provenance into a complete state. */ +export function sanitizeAppearance(value: unknown): AppearanceState { + const raw = typeof value === "object" && value !== null ? value : {}; + const source = raw as Record; + return APPEARANCE_SETTINGS.reduce( + (state, setting) => ({ + ...state, + [setting.key]: setting.sanitize(source[setting.key]), + }), + { ...APPEARANCE_DEFAULTS }, + ); +} + +export function readAppearance(): AppearanceState { + try { + return sanitizeAppearance(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")); + } catch { + // Appearance is a convenience; unreadable storage must not block startup. + return { ...APPEARANCE_DEFAULTS }; + } +} + +export function writeAppearance(state: AppearanceState): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // Quota or a private-mode window: the session still honours the choice. + } +} + +/** + * Push the state onto `root`, clearing anything left at its default so the + * stylesheet's own value shows through instead of a duplicate copy of it. + */ +export function applyAppearance(state: AppearanceState, root: HTMLElement): void { + for (const setting of APPEARANCE_SETTINGS) { + if (!setting.cssVariable) continue; + const value = state[setting.key] as never; + const rendered = setting.toCss ? setting.toCss(value) : String(value); + if (rendered === "" || value === APPEARANCE_DEFAULTS[setting.key]) { + root.style.removeProperty(setting.cssVariable); + } else { + root.style.setProperty(setting.cssVariable, rendered); + } + } + + if (state.theme === "system") { + root.removeAttribute("data-theme"); + } else { + root.setAttribute("data-theme", state.theme); + } +} diff --git a/desktop/src/app/useAppearance.ts b/desktop/src/app/useAppearance.ts new file mode 100644 index 00000000..7e95d2c8 --- /dev/null +++ b/desktop/src/app/useAppearance.ts @@ -0,0 +1,84 @@ +import { useCallback, useSyncExternalStore } from "react"; + +import { + APPEARANCE_DEFAULTS, + applyAppearance, + readAppearance, + writeAppearance, + type AppearanceState, +} from "./appearance"; + +/** + * One module-level store, read through `useSyncExternalStore`. + * + * The shell mounts this to apply saved preferences at startup and the settings + * page mounts it to edit them. Per-component `useState` would give those two + * separate copies that silently disagree, and threading a controller down + * through the workspace would be prop drilling for a value that is neither + * server state nor scoped to a subtree. `useSystemDarkMode` uses the same + * pattern for the same reason. + */ + +let state: AppearanceState = readAppearance(); +let applied = false; +const listeners = new Set<() => void>(); + +function root(): HTMLElement | null { + return typeof document === "undefined" ? null : document.documentElement; +} + +function flush(): void { + const element = root(); + if (element) applyAppearance(state, element); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + // The first subscriber marks the app as mounted; paint the saved + // preferences now rather than waiting for the first edit. + if (!applied) { + applied = true; + flush(); + } + return () => listeners.delete(listener); +} + +function snapshot(): AppearanceState { + return state; +} + +function commit(next: AppearanceState): void { + state = next; + writeAppearance(state); + flush(); + for (const listener of listeners) listener(); +} + +export interface AppearanceController { + appearance: AppearanceState; + /** Update one preference; the others are untouched. */ + set(key: K, value: AppearanceState[K]): void; + reset(): void; +} + +export function useAppearance(): AppearanceController { + const appearance = useSyncExternalStore(subscribe, snapshot, snapshot); + + const set = useCallback( + (key: K, value: AppearanceState[K]) => { + commit({ ...state, [key]: value }); + }, + [], + ); + + const reset = useCallback(() => commit({ ...APPEARANCE_DEFAULTS }), []); + + return { appearance, set, reset }; +} + +/** Reset module state between tests. */ +export function __resetAppearanceStoreForTests(): void { + state = readAppearance(); + applied = false; + listeners.clear(); +} diff --git a/desktop/src/features/execution/Composer.module.css b/desktop/src/features/execution/Composer.module.css index f80fc670..bdc36908 100644 --- a/desktop/src/features/execution/Composer.module.css +++ b/desktop/src/features/execution/Composer.module.css @@ -16,7 +16,7 @@ } .composer { - width: min(820px, 100%); + width: var(--conversation-width); margin: 0 auto; overflow: visible; border: 1px solid var(--border-strong); diff --git a/desktop/src/features/goal/GoalRail.module.css b/desktop/src/features/goal/GoalRail.module.css index 347e3912..cce9252f 100644 --- a/desktop/src/features/goal/GoalRail.module.css +++ b/desktop/src/features/goal/GoalRail.module.css @@ -1,6 +1,6 @@ .emptyRail, .rail { - width: min(820px, 100%); + width: var(--conversation-width); margin: 0 auto 8px; pointer-events: auto; } diff --git a/desktop/src/features/settings/AppearanceSettings.tsx b/desktop/src/features/settings/AppearanceSettings.tsx new file mode 100644 index 00000000..04c46483 --- /dev/null +++ b/desktop/src/features/settings/AppearanceSettings.tsx @@ -0,0 +1,138 @@ +import { useId } from "react"; + +import { + APPEARANCE_DEFAULTS, + APPEARANCE_SETTINGS, + THEME_PREFERENCES, + type AppearanceState, + type ThemePreference, +} from "../../app/appearance"; +import { useAppearance } from "../../app/useAppearance"; +import styles from "../management/ManagementWorkspace.module.css"; + +const THEME_LABELS: Record = { + system: "Match system", + light: "Light", + dark: "Dark", +}; + +/** + * Appearance controls, rendered from `APPEARANCE_SETTINGS`. + * + * The table owns each preference's label, range and validation, so adding one + * is a row there rather than another block of near-identical JSX here. + * Changes apply immediately and persist locally — these are per-machine + * display choices, not project configuration, so there is nothing to save. + */ +export function AppearanceSettings() { + const { appearance, set, reset } = useAppearance(); + const fieldId = useId(); + const isDefault = APPEARANCE_SETTINGS.every( + (setting) => appearance[setting.key] === APPEARANCE_DEFAULTS[setting.key], + ); + + return ( +
+
+
+

Display

+

Appearance

+
+
+ +
+ {APPEARANCE_SETTINGS.map((setting) => { + const id = `${fieldId}-${setting.key}`; + const value = appearance[setting.key]; + + if (setting.key === "theme") { + return ( + + ); + } + + if (setting.range) { + const { min, max, step, unit } = setting.range; + return ( + + ); + } + + return ( + + ); + })} +
+ +

+ { + APPEARANCE_SETTINGS.find((setting) => setting.key === "fontFamily") + ?.description + } +

+ +
+ + These are per-machine display settings. They apply immediately and + are not part of project configuration. + + +
+
+ ); +} + +export type { AppearanceState }; diff --git a/desktop/src/features/settings/SettingsPage.tsx b/desktop/src/features/settings/SettingsPage.tsx index b4f783f3..6ded1410 100644 --- a/desktop/src/features/settings/SettingsPage.tsx +++ b/desktop/src/features/settings/SettingsPage.tsx @@ -21,6 +21,7 @@ import type { DesktopUpdateProgress, } from "../../rpc/contracts"; import { useDiagnostics } from "./useDiagnostics"; +import { AppearanceSettings } from "./AppearanceSettings"; import { ConnectionSettings } from "./ConnectionSettings"; import { ConnectionVerification } from "./ConnectionVerification"; import { @@ -552,6 +553,8 @@ export function SettingsPage({ + +
diff --git a/desktop/src/features/thread/ThreadConversation.module.css b/desktop/src/features/thread/ThreadConversation.module.css index efced626..5aea0bf9 100644 --- a/desktop/src/features/thread/ThreadConversation.module.css +++ b/desktop/src/features/thread/ThreadConversation.module.css @@ -14,7 +14,7 @@ } .conversation { - width: min(820px, 100%); + width: var(--conversation-width); margin: 0 auto; padding: 42px 30px 202px; } diff --git a/desktop/src/styles/tokens.css b/desktop/src/styles/tokens.css index 36cab145..f8417412 100644 --- a/desktop/src/styles/tokens.css +++ b/desktop/src/styles/tokens.css @@ -2,9 +2,23 @@ color: #202321; background: #edf0ee; color-scheme: light dark; + + /* Appearance preferences (src/app/appearance.ts) override the three + variables below on the root element. They are declared here so the app + works untouched when nothing has been customised, and so the defaults + stay in the stylesheet rather than being duplicated in TypeScript. */ + + /* Empty by default; the preference prepends families to the stack. */ + --font-ui-preferred: ; + --font-size-base: 14px; + /* Width of the conversation column, its composer, and the goal rail — + one value so the three cannot drift apart. */ + --conversation-width: min(820px, 100%); + font-family: - -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI Variable", - "Segoe UI", Inter, sans-serif; + var(--font-ui-preferred) -apple-system, BlinkMacSystemFont, "SF Pro Text", + "Segoe UI Variable", "Segoe UI", Inter, sans-serif; + font-size: var(--font-size-base); font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; @@ -62,8 +76,10 @@ 0 18px 42px rgb(27 34 30 / 14%), 0 3px 10px rgb(27 34 30 / 7%); } +/* System preference. The :not() lets an explicit light choice win here, + and the rule below lets an explicit dark choice win on a light OS. */ @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme="light"]) { color: #f1f4f2; background: #101211; @@ -108,6 +124,53 @@ } } +/* Explicit dark, whatever the OS says. CSS cannot share one declaration + block across a media query and a selector, so this palette is stated + twice — tokens.test.ts fails if the two ever disagree. */ +:root[data-theme="dark"] { + color: #f1f4f2; + background: #101211; + + --surface-shell: #101211; + --surface-canvas: #181b19; + --surface-sidebar: #151816; + --surface-sidebar-strong: #1b1f1c; + --surface-raised: #222624; + --surface-overlay: rgb(29 33 30 / 88%); + --surface-hover: #292e2b; + --surface-selected: #303632; + --surface-user: #282d2a; + --surface-code: #151816; + --surface-inset: #121513; + + --text-primary: #f1f4f2; + --text-secondary: #b3bab5; + --text-tertiary: #808982; + --text-inverse: #151816; + --text-on-accent: #ffffff; + + --border-subtle: #2b302d; + --border-strong: #3a413d; + --border-emphasis: #59625d; + + --signal: #929cff; + --signal-strong: #aab2ff; + --signal-soft: rgb(146 156 255 / 16%); + --signal-faint: rgb(146 156 255 / 8%); + --success: #6abb9c; + --success-soft: rgb(63 125 103 / 17%); + --attention: #d79058; + --attention-soft: rgb(178 100 43 / 14%); + --danger: #e4777e; + --danger-soft: rgb(179 64 73 / 14%); + + --shadow-soft: 0 1px 2px rgb(0 0 0 / 16%); + --shadow-float: + 0 20px 52px rgb(0 0 0 / 32%), 0 3px 10px rgb(0 0 0 / 22%); + --shadow-menu: + 0 20px 46px rgb(0 0 0 / 42%), 0 3px 10px rgb(0 0 0 / 26%); +} + * { box-sizing: border-box; } diff --git a/desktop/src/styles/tokens.test.ts b/desktop/src/styles/tokens.test.ts new file mode 100644 index 00000000..be7722b7 --- /dev/null +++ b/desktop/src/styles/tokens.test.ts @@ -0,0 +1,92 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +// Vitest runs from the Vite root (desktop/); jsdom leaves import.meta.url as +// a non-file URL, so resolve against the project root instead. +const CSS = readFileSync(resolve("src/styles/tokens.css"), "utf8"); + +/** Declarations inside the first `{ … }` that follows `selector`. */ +function declarationsAfter(selector: string): Map { + const start = CSS.indexOf(selector); + if (start < 0) throw new Error(`selector not found: ${selector}`); + + let depth = 0; + let bodyStart = -1; + let index = start; + for (; index < CSS.length; index += 1) { + if (CSS[index] === "{") { + depth += 1; + if (depth === 1) bodyStart = index + 1; + } else if (CSS[index] === "}") { + depth -= 1; + if (depth === 0) break; + } + } + + const body = CSS.slice(bodyStart, index); + const declarations = new Map(); + for (const [, property, value] of body.matchAll( + /([\w-]+)\s*:\s*([^;]+);/g, + )) { + declarations.set(property.trim(), value.replace(/\s+/g, " ").trim()); + } + return declarations; +} + +describe("dark palette", () => { + // CSS cannot share one declaration block between a media query and a + // selector, so the dark values are written twice. Copies drift silently — + // exactly the failure this project hit in its model catalog — so the copy + // is pinned here rather than trusted. + // The inner rule, not the @media wrapper: starting at the wrapper would + // make the selector line itself look like a declaration. + const fromMediaQuery = declarationsAfter(':root:not([data-theme="light"])'); + const fromAttribute = declarationsAfter(':root[data-theme="dark"]'); + + it("declares the same properties in both places", () => { + expect([...fromAttribute.keys()].sort()).toEqual( + [...fromMediaQuery.keys()].sort(), + ); + }); + + it("gives every property the same value in both places", () => { + expect(Object.fromEntries(fromAttribute)).toEqual( + Object.fromEntries(fromMediaQuery), + ); + }); + + it("covers the palette rather than a token or two", () => { + // A guard against the parser silently matching an empty block. + expect(fromMediaQuery.size).toBeGreaterThan(25); + }); +}); + +describe("theme override selectors", () => { + it("lets an explicit light choice beat a dark OS", () => { + expect(CSS).toContain(':root:not([data-theme="light"])'); + }); + + it("lets an explicit dark choice beat a light OS", () => { + expect(CSS).toContain(':root[data-theme="dark"]'); + }); +}); + +describe("appearance variables", () => { + const rootDeclarations = declarationsAfter(":root {"); + + it.each(["--conversation-width", "--font-size-base", "--font-ui-preferred"])( + "declares a default for %s", + (variable) => { + expect(rootDeclarations.has(variable)).toBe(true); + }, + ); + + it("routes the root font through both variables", () => { + expect(rootDeclarations.get("font-family")).toContain( + "var(--font-ui-preferred)", + ); + expect(rootDeclarations.get("font-size")).toBe("var(--font-size-base)"); + }); +});