diff --git a/backend/app.py b/backend/app.py
index d94a9db..6226c64 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -158,7 +158,7 @@ def ping(*args):
# to - both must exist before register_canvas builds the sendMessage
# intent that calls them.
token_counter = register_token_counter(bus)
- composer_document = register_composer(bus, token_counter)
+ composer_document = register_composer(bus, token_counter, settings_manager, notifications_state)
# R4 (doc/QT_REMOVAL_PLAN.md): the agent-dispatch service - one
# AgentDispatcher per session (never a module-level singleton). Bolted
diff --git a/backend/composer.py b/backend/composer.py
index d7029fc..c42d99f 100644
--- a/backend/composer.py
+++ b/backend/composer.py
@@ -34,8 +34,13 @@
from typing import Any
from uuid import uuid4
+import api_provider
+
from backend.events import SessionBus
+from backend.notifications import NotificationState
+from backend.settings import apply_llama_cpp_reasoning_mode, apply_ollama_reasoning_mode
from backend.token_counter import TokenCounterState
+from graphlink_licensing import SettingsManager
REASONING_OPTIONS = [
{"id": "thinking", "label": "Thinking Mode (Enable CoT)", "description": "Slower, higher-quality reasoning."},
@@ -134,14 +139,25 @@ def payload(self) -> dict[str, Any]:
"contextReview": False,
"routeSelection": False,
"modelSelection": False,
- "reasoningSelection": True,
+ "reasoningSelection": api_provider.is_local_ollama_mode() or api_provider.is_local_llama_cpp_mode(),
"settingsShortcut": True,
"cancellation": True,
},
}
-def register_composer(bus: SessionBus, token_counter: TokenCounterState) -> ComposerDocument:
+def register_composer(
+ bus: SessionBus,
+ token_counter: TokenCounterState,
+ settings_manager: SettingsManager | None = None,
+ notifications: NotificationState | None = None,
+) -> ComposerDocument:
+ # settings_manager/notifications are optional only so the 2-positional-
+ # arg call in backend/tests/test_backend_composer.py's make_bus()
+ # (register_composer(bus, counter)) keeps working unchanged - the real
+ # (and only) production call site, backend/app.py's _configure_session,
+ # always passes both (mirroring register_settings's own `notifications`
+ # optional-param precedent exactly).
document = ComposerDocument()
bus.register_topic("app-composer", document.payload)
@@ -156,7 +172,45 @@ async def update_draft(text):
await bus.publish("token-counter")
async def set_reasoning_level(level):
+ # Busy-guard, matching legacy's setReasoningLevel exactly
+ # (graphlink_composer_bridge.py, ~line 249): a bare no-op - no
+ # exception, no publish - while a request is in flight. This
+ # document only ever has two request_state values
+ # ("idle"/"generating"), already exposed on the wire as
+ # request.canSend == (request_state == "idle") - reuse that existing
+ # fact rather than adding a second field for it.
+ if document.request_state != "idle":
+ return
+
+ # Unchanged existing behavior: raises ComposerError for an
+ # unrecognized id (see
+ # test_set_reasoning_level_intent_updates_and_rejects_unknown).
document.set_reasoning_level(level)
+
+ # Same Title-Case normalization backend/settings.py's own
+ # _OLLAMA_REASONING_MODES/_LLAMA_CPP_REASONING_MODES validate
+ # against, and legacy's own bridge applied. This ternary is TOTAL -
+ # it always produces "Thinking" or "Quick" - so the shared apply
+ # functions below never need to re-validate whatever this handler
+ # passes them.
+ normalized_mode = "Thinking" if str(level).strip().lower() == "thinking" else "Quick"
+
+ if settings_manager is not None:
+ # Mutually exclusive by construction (api_provider.py) - order
+ # between the two branches is arbitrary; Ollama-first here
+ # purely to match backend/settings.py's own file ordering
+ # (Ollama defined before Llama.cpp throughout that file).
+ if api_provider.is_local_ollama_mode():
+ await apply_ollama_reasoning_mode(settings_manager, normalized_mode)
+ elif api_provider.is_local_llama_cpp_mode():
+ failure = await apply_llama_cpp_reasoning_mode(settings_manager, normalized_mode)
+ if failure is not None and notifications is not None:
+ notifications.show(failure, "error")
+ await bus.publish("notification")
+ # else: cloud/API mode - reasoningSelection is already False
+ # there (see payload() above), so the UI disables this control
+ # entirely; skip the apply step, never raise.
+
await publish()
bus.register_intent("app-composer", "updateDraft", update_draft)
diff --git a/backend/settings.py b/backend/settings.py
index 944fcb3..60877ff 100644
--- a/backend/settings.py
+++ b/backend/settings.py
@@ -140,6 +140,51 @@ def _locked_llama_cpp_settings(manager: SettingsManager) -> dict[str, Any]:
return manager.get_llama_cpp_settings()
+async def apply_ollama_reasoning_mode(manager: SettingsManager, mode: str) -> None:
+ """Persist `mode` and, if Ollama is still the live provider, re-apply it to
+ api_provider's module state so the very next chat()/chat_stream() call
+ picks it up. `mode` MUST already be "Thinking" or "Quick" - this function
+ does not re-validate it (every caller's own normalization is total, so
+ there is no path that could hand it anything else). Checked-and-applied
+ inside the SAME asyncio.to_thread hop as a deliberate race-preemption:
+ see set_ollama_reasoning_mode's inline comment for why. Shared by this
+ file's own setOllamaReasoningMode intent (Settings-dialog Ollama page)
+ and backend/composer.py's setReasoningLevel intent (composer's own
+ quick-access popover) so both surfaces apply a change identically."""
+ await asyncio.to_thread(_apply, manager.set_ollama_reasoning_mode, mode)
+
+ def _reapply_if_ollama_is_still_the_live_provider() -> None:
+ if api_provider.is_local_ollama_mode():
+ api_provider.initialize_local_provider(config.LOCAL_PROVIDER_OLLAMA, {"reasoning_mode": mode})
+
+ await asyncio.to_thread(_reapply_if_ollama_is_still_the_live_provider)
+
+
+async def apply_llama_cpp_reasoning_mode(manager: SettingsManager, mode: str) -> str | None:
+ """Same contract as apply_ollama_reasoning_mode, for Llama.cpp. Returns a
+ human-readable failure message if the live re-apply fails (the mode is
+ ALREADY persisted regardless - only the live effect is delayed to the
+ next mode switch/restart), or None on success / when Llama.cpp is not the
+ active provider. Returns rather than writes a notice directly: this
+ file's own setLlamaCppReasoningMode intent writes the message into its
+ page-local llama_notice cell; backend/composer.py's intent has no such
+ cell and surfaces it through the shared NotificationState banner
+ instead - that decision belongs to each caller, not to this function."""
+ await asyncio.to_thread(_apply, manager.set_llama_cpp_reasoning_mode, mode)
+
+ def _reapply_if_llama_cpp_is_still_the_live_provider() -> None:
+ if api_provider.is_local_llama_cpp_mode():
+ settings = _locked_llama_cpp_settings(manager)
+ settings["reasoning_mode"] = mode
+ api_provider.initialize_local_provider(config.LOCAL_PROVIDER_LLAMACPP, settings)
+
+ try:
+ await asyncio.to_thread(_reapply_if_llama_cpp_is_still_the_live_provider)
+ except Exception as exc: # noqa: BLE001 - no secret in this path (a local file path, not a credential)
+ return f"Reasoning mode saved, but could not be applied to the live model: {exc}"
+ return None
+
+
def _redact(text: str, secret: Any) -> str:
# Post-review fix: some HTTP client libraries embed request parameters
# (including a rejected API key) directly in exception text - the
@@ -642,40 +687,37 @@ def _reset() -> None:
await bus.publish("app-settings")
async def set_ollama_reasoning_mode(mode: str):
+ # Checked and applied inside the SAME to_thread hop, not split across
+ # an await boundary: a concurrent mode switch (the toolbar's provider
+ # selector) landing in a gap between a separate check and a later
+ # apply could otherwise force the live provider back to Ollama after
+ # the user had already switched away - the same class of
+ # stale-check-then-await race the R7.4a audit found and fixed
+ # elsewhere in this file.
+ # Legacy-parity note (corrected after adversarial review): this live
+ # re-apply is NOT what legacy's own settings dialog did.
+ # graphlink_settings_bridge.py's setOllamaReasoningMode called
+ # _reinitialize_main_window_agent() unconditionally, which just
+ # rebuilds ChatAgent - it never touched the live
+ # OLLAMA_REASONING_MODE global api_provider.py's chat dispatch
+ # actually reads. Legacy's ONLY path that ever updated that global
+ # was a completely separate control - the composer toolbar's
+ # setReasoningLevel - which the Settings dialog was never wired to.
+ # So in real legacy usage, changing reasoning mode via Settings
+ # persisted the value but left the live think= kwarg stale until the
+ # next provider-mode switch or restart. This re-apply is a genuine
+ # fix for that gap, not a port of existing legacy behavior - gated on
+ # is_local_ollama_mode() so it can't be the mechanism that forces a
+ # user who already switched to a different provider back onto
+ # Ollama.
+ #
+ # R7.5d: this apply/re-apply sequence is now shared with
+ # backend/composer.py's own setReasoningLevel intent - see
+ # apply_ollama_reasoning_mode's own docstring above.
mode = str(mode)
if mode not in _OLLAMA_REASONING_MODES:
return
- await asyncio.to_thread(_apply, manager.set_ollama_reasoning_mode, mode)
-
- def _reapply_if_ollama_is_still_the_live_provider() -> None:
- # Checked and applied inside the SAME to_thread hop, not split
- # across an await boundary: a concurrent mode switch (the
- # toolbar's provider selector) landing in a gap between a
- # separate check and a later apply could otherwise force the
- # live provider back to Ollama after the user had already
- # switched away - the same class of stale-check-then-await
- # race the R7.4a audit found and fixed elsewhere in this file.
- # Legacy-parity note (corrected after adversarial review): this
- # live re-apply is NOT what legacy's own settings dialog did.
- # graphlink_settings_bridge.py's setOllamaReasoningMode called
- # _reinitialize_main_window_agent() unconditionally, which just
- # rebuilds ChatAgent - it never touched the live
- # OLLAMA_REASONING_MODE global api_provider.py's chat dispatch
- # actually reads. Legacy's ONLY path that ever updated that
- # global was a completely separate control - the composer
- # toolbar's setReasoningLevel - which the Settings dialog was
- # never wired to. So in real legacy usage, changing reasoning
- # mode via Settings persisted the value but left the live
- # think= kwarg stale until the next provider-mode switch or
- # restart. This re-apply is a genuine fix for that gap, not a
- # port of existing legacy behavior - gated on
- # is_local_ollama_mode() so it can't be the mechanism that
- # forces a user who already switched to a different provider
- # back onto Ollama.
- if api_provider.is_local_ollama_mode():
- api_provider.initialize_local_provider(config.LOCAL_PROVIDER_OLLAMA, {"reasoning_mode": mode})
-
- await asyncio.to_thread(_reapply_if_ollama_is_still_the_live_provider)
+ await apply_ollama_reasoning_mode(manager, mode)
await bus.publish("app-settings")
async def set_ollama_model_assignment(task: str, value: str):
@@ -866,33 +908,31 @@ def _persist() -> None:
await bus.publish("app-settings")
async def set_llama_cpp_reasoning_mode(mode: str):
+ # Same shape as set_ollama_reasoning_mode's own live re-apply: gated
+ # on is_local_llama_cpp_mode(), checked and applied inside the SAME
+ # to_thread hop so a concurrent provider-mode switch can't be
+ # clobbered back to Llama.cpp. Unlike Ollama's version, this
+ # genuinely CAN fail: initialize_local_provider's Llama.cpp branch
+ # re-validates chat_model_path (must still be a real, existing .gguf
+ # file) every time it runs - if that file was deleted/moved from
+ # under an already-active session since Llama.cpp was last
+ # activated, this raises. The mode is already persisted regardless,
+ # so a failure here only means it takes effect on the next mode
+ # switch/restart instead of immediately - not a lost setting.
+ #
+ # R7.5d: this apply/re-apply sequence is now shared with
+ # backend/composer.py's own setReasoningLevel intent - see
+ # apply_llama_cpp_reasoning_mode's own docstring above.
mode = str(mode)
if mode not in _LLAMA_CPP_REASONING_MODES:
return
- await asyncio.to_thread(_apply, manager.set_llama_cpp_reasoning_mode, mode)
-
- def _reapply_if_llama_cpp_is_still_the_live_provider() -> None:
- # Same shape as set_ollama_reasoning_mode's own live re-apply:
- # gated on is_local_llama_cpp_mode(), checked and applied inside
- # this SAME to_thread hop so a concurrent provider-mode switch
- # can't be clobbered back to Llama.cpp. Unlike Ollama's version,
- # this genuinely CAN fail: initialize_local_provider's
- # Llama.cpp branch re-validates chat_model_path (must still be
- # a real, existing .gguf file) every time it runs - if that
- # file was deleted/moved from under an already-active session
- # since Llama.cpp was last activated, this raises. The mode is
- # already persisted above regardless, so a failure here only
- # means it takes effect on the next mode switch/restart instead
- # of immediately - not a lost setting.
- if api_provider.is_local_llama_cpp_mode():
- settings = _locked_llama_cpp_settings(manager)
- settings["reasoning_mode"] = mode
- api_provider.initialize_local_provider(config.LOCAL_PROVIDER_LLAMACPP, settings)
-
- try:
- await asyncio.to_thread(_reapply_if_llama_cpp_is_still_the_live_provider)
- except Exception as exc: # noqa: BLE001 - no secret in this path (a local file path, not a credential)
- llama_notice["value"] = f"Reasoning mode saved, but could not be applied to the live model: {exc}"
+ failure = await apply_llama_cpp_reasoning_mode(manager, mode)
+ if failure is not None:
+ llama_notice["value"] = failure
+ # NOTE: on success, do NOT clear llama_notice to "" - this preserves
+ # the exact pre-existing behavior (today's code never clears it on
+ # success either); do not "improve" this as a drive-by fix in this
+ # increment.
await bus.publish("app-settings")
async def set_llama_cpp_chat_format(chat_format: str):
diff --git a/backend/tests/test_backend_composer.py b/backend/tests/test_backend_composer.py
index 9b74588..b611c78 100644
--- a/backend/tests/test_backend_composer.py
+++ b/backend/tests/test_backend_composer.py
@@ -4,6 +4,10 @@
import pytest
+import api_provider
+import graphlink_task_config as config
+from graphlink_licensing import SettingsManager
+
from backend.composer import ComposerDocument, ComposerError, register_composer
from backend.events import SessionBus
from backend.notifications import NotificationState, register_notifications
@@ -31,10 +35,31 @@ def make_bus():
return bus, composer, counter, notifications, recorder
+def _make_bus_with_settings(settings_manager):
+ # R7.5d: a small, self-contained helper for the reasoning-toggle tests
+ # below, which need a real SettingsManager wired all the way through
+ # register_composer - kept separate from make_bus() above (which must
+ # keep working exactly as it does today for the pre-existing tests).
+ bus = SessionBus("composer-reasoning-test")
+ counter = register_token_counter(bus)
+ notifications = register_notifications(bus)
+ composer = register_composer(bus, counter, settings_manager, notifications)
+ recorder = Recorder()
+ bus.attach(recorder)
+ return bus, composer, notifications, recorder
+
+
# -- composer ----------------------------------------------------------------
-def test_default_payload_matches_generated_validator_shape():
+def test_default_payload_matches_generated_validator_shape(monkeypatch):
+ # R7.5d: reasoningSelection is now computed from live api_provider
+ # module state (see test_reasoning_selection_capability_reflects_
+ # active_local_provider below), not hardcoded - pin it here so this
+ # shape assertion is deterministic regardless of any other test's
+ # ambient global provider state.
+ monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: True)
+ monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False)
payload = ComposerDocument().payload()
assert set(payload) == {"draft", "context", "route", "request", "capabilities"}
assert set(payload["draft"]) == {"id", "text", "contextMode", "sendMode", "restored"}
@@ -68,6 +93,128 @@ async def run():
asyncio.run(run())
+def test_reasoning_selection_capability_reflects_active_local_provider(monkeypatch):
+ monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: True)
+ monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False)
+ assert ComposerDocument().payload()["capabilities"]["reasoningSelection"] is True
+
+ monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False)
+ monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True)
+ assert ComposerDocument().payload()["capabilities"]["reasoningSelection"] is True
+
+ monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False)
+ monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False)
+ assert ComposerDocument().payload()["capabilities"]["reasoningSelection"] is False
+
+
+def test_set_reasoning_level_reapplies_live_when_ollama_is_active(tmp_path, monkeypatch):
+ manager = SettingsManager(tmp_path / "session.dat")
+ monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: True)
+ monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False)
+ calls = []
+ monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a))
+
+ async def run():
+ bus, document, _, _ = _make_bus_with_settings(manager)
+ await bus.dispatch_intent("app-composer", "setReasoningLevel", ["thinking"])
+
+ asyncio.run(run())
+
+ assert manager.get_ollama_reasoning_mode() == "Thinking"
+ assert calls == [(config.LOCAL_PROVIDER_OLLAMA, {"reasoning_mode": "Thinking"})]
+
+
+def test_set_reasoning_level_reapplies_live_when_llama_cpp_is_active(tmp_path, monkeypatch):
+ manager = SettingsManager(tmp_path / "session.dat")
+ monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False)
+ monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True)
+ calls = []
+ monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a))
+
+ async def run():
+ bus, document, _, _ = _make_bus_with_settings(manager)
+ await bus.dispatch_intent("app-composer", "setReasoningLevel", ["quick"])
+
+ asyncio.run(run())
+
+ assert manager.get_llama_cpp_reasoning_mode() == "Quick"
+ assert len(calls) == 1
+ provider, settings = calls[0]
+ assert provider == config.LOCAL_PROVIDER_LLAMACPP
+ assert settings["reasoning_mode"] == "Quick"
+
+
+def test_set_reasoning_level_llama_cpp_reapply_failure_surfaces_a_notification(tmp_path, monkeypatch):
+ manager = SettingsManager(tmp_path / "session.dat")
+ monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False)
+ monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True)
+
+ def _boom(*a, **k):
+ raise RuntimeError("boom")
+
+ monkeypatch.setattr(api_provider, "initialize_local_provider", _boom)
+
+ async def run():
+ bus, document, notifications, recorder = _make_bus_with_settings(manager)
+ await bus.dispatch_intent("app-composer", "setReasoningLevel", ["thinking"])
+ return notifications, recorder
+
+ notifications, recorder = asyncio.run(run())
+
+ payload = notifications.payload()
+ assert payload["visible"] is True
+ assert "boom" in payload["message"]
+ assert recorder.topics_seen().count("notification") == 1
+ # The notification path must not short-circuit before the handler's own
+ # trailing publish - a future edit that early-returns after showing the
+ # notification would silently stop the composer UI from ever reflecting
+ # the persisted reasoning_level change.
+ assert recorder.topics_seen().count("app-composer") == 1
+ # Persisted despite the live-apply failure.
+ assert manager.get_llama_cpp_reasoning_mode() == "Thinking"
+
+
+def test_set_reasoning_level_skips_apply_in_cloud_mode(tmp_path, monkeypatch):
+ manager = SettingsManager(tmp_path / "session.dat")
+ monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False)
+ monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False)
+ calls = []
+ monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a))
+
+ async def run():
+ bus, document, _, recorder = _make_bus_with_settings(manager)
+ await bus.dispatch_intent("app-composer", "setReasoningLevel", ["thinking"])
+ return document, recorder
+
+ document, recorder = asyncio.run(run())
+
+ assert document.reasoning_level == "thinking"
+ assert calls == []
+ assert recorder.topics_seen().count("app-composer") == 1
+
+
+def test_set_reasoning_level_busy_guard_no_ops_even_with_settings_manager(tmp_path, monkeypatch):
+ manager = SettingsManager(tmp_path / "session.dat")
+ monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: True)
+ monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False)
+ calls = []
+ monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a))
+
+ async def run():
+ bus, document, _, recorder = _make_bus_with_settings(manager)
+ document.begin_request("req-1")
+ publishes_before = recorder.topics_seen().count("app-composer")
+ await bus.dispatch_intent("app-composer", "setReasoningLevel", ["thinking"])
+ publishes_after = recorder.topics_seen().count("app-composer")
+ return document, publishes_before, publishes_after
+
+ document, publishes_before, publishes_after = asyncio.run(run())
+
+ assert document.reasoning_level == "quick"
+ assert calls == []
+ assert publishes_after == publishes_before
+
+
# -- R4: request lifecycle (begin_request/end_request) ------------------------
diff --git a/web_ui/src/app/chrome/Composer.test.tsx b/web_ui/src/app/chrome/Composer.test.tsx
index d184526..70d5646 100644
--- a/web_ui/src/app/chrome/Composer.test.tsx
+++ b/web_ui/src/app/chrome/Composer.test.tsx
@@ -238,6 +238,7 @@ describe("Composer", () => {
],
},
},
+ request: { ...initialComposerState.request, canSend: true },
},
});
render(
@@ -251,6 +252,46 @@ describe("Composer", () => {
expect(setReasoningLevel).toHaveBeenCalledWith("thinking");
expect(screen.queryByText("Thinking Mode (Enable CoT)")).toBeNull();
});
+
+ it("Reasoning trigger is disabled when reasoningSelection is false", () => {
+ const { store } = makeStore({
+ composer: {
+ capabilities: { ...initialComposerState.capabilities, reasoningSelection: false },
+ request: { ...initialComposerState.request, canSend: true },
+ },
+ });
+ const { container } = render(
+
+ {/* @ts-expect-error - test double */}
+
+ ,
+ );
+ expect(container.querySelector('[data-overlay-trigger="reasoning"]')).toBeDisabled();
+ });
+
+ it("Reasoning trigger is disabled while a request is busy even when reasoningSelection is true", () => {
+ const { store } = makeStore();
+ const { container } = render(
+
+ {/* @ts-expect-error - test double */}
+
+ ,
+ );
+ expect(container.querySelector('[data-overlay-trigger="reasoning"]')).toBeDisabled();
+ });
+
+ it("Reasoning trigger is enabled when reasoningSelection is true and canSend is true", () => {
+ const { store } = makeStore({
+ composer: { request: { ...initialComposerState.request, canSend: true } },
+ });
+ const { container } = render(
+
+ {/* @ts-expect-error - test double */}
+
+ ,
+ );
+ expect(container.querySelector('[data-overlay-trigger="reasoning"]')).not.toBeDisabled();
+ });
});
describe("TokenCounter", () => {
diff --git a/web_ui/src/app/chrome/Composer.tsx b/web_ui/src/app/chrome/Composer.tsx
index f5ff1a3..1d836ae 100644
--- a/web_ui/src/app/chrome/Composer.tsx
+++ b/web_ui/src/app/chrome/Composer.tsx
@@ -107,6 +107,7 @@ export function Composer({ store, sceneStore }: { store: ComposerStore; sceneSto
data-overlay-trigger="reasoning"
aria-haspopup="dialog"
aria-pressed={overlays.isOpen("reasoning")}
+ disabled={!composer.capabilities.reasoningSelection || !composer.request.canSend}
onClick={() => overlays.toggle("reasoning", "popover")}
>
Reasoning