Files
fusion/packages/dashboard/app/components/ChatThinkingLevelControl.tsx
Fusion Agent 4d7e7dba4c FN-021: expose model-supported thinking levels, including max
Expose thinking-level options from model capabilities through API, workflows, and dashboard controls.

- Add model-supported thinking-level metadata, including max, across core and model routes.
- Propagate capability-aware levels through planning, agent creation, and chat/task UI.
- Update localized labels, documentation, tests, dependencies, and add a release changeset.

Files changed:
 .changeset/fn-021-thinking-levels.md               |  7 ++++
 docs/settings-reference.md                         |  8 +++-
 docs/workflow-steps.md                             |  8 ++--
 packages/core/src/__tests__/openai-models.test.ts  |  1 +
 .../core/src/__tests__/thinking-levels.test.ts     |  4 +-
 packages/core/src/__tests__/workflow-ir.test.ts    |  2 +-
 packages/core/src/ai/openai-models.ts              |  5 ++-
 packages/core/src/types/board/board.ts             |  8 ++--
 .../app/api/agents/agent-import-generation.ts      |  4 +-
 .../dashboard/app/api/planning/models-usage.ts     | 13 +++++++
 packages/dashboard/app/api/planning/planning.ts    |  5 ++-
 .../dashboard/app/components/AgentDetailView.tsx   |  2 +-
 .../app/components/ChatThinkingLevelControl.tsx    | 28 +++++++++++---
 .../app/components/CustomModelDropdown.tsx         | 29 ++++++++++----
 packages/dashboard/app/components/NewTaskModal.tsx |  4 +-
 .../dashboard/app/components/TaskDetailModal.tsx   |  4 +-
 .../__tests__/AgentDetailView.test-helpers.ts      |  2 +-
 .../__tests__/AgentOnboardingModal.test.tsx        |  2 +-
 .../__tests__/ChatThinkingLevelControl.test.tsx    | 27 ++++++++++++-
 .../__tests__/CustomModelDropdown.test.tsx         | 33 +++++++++++++++-
 .../__tests__/ModelSelectionModal.test.tsx         |  3 +-
 .../components/__tests__/NewAgentDialog.test.tsx   |  2 +
 .../components/__tests__/QuickEntryBox.test.tsx    |  3 +-
 .../components/agent-presets/agentCreatePayload.ts |  3 +-
 .../settings/sections/GlobalModelsSection.tsx      |  8 ++--
 packages/dashboard/package.json                    |  2 +-
 .../dashboard/src/__tests__/chat-manager.test.ts   |  8 ++--
 packages/dashboard/src/__tests__/chat.test.ts      |  2 +-
 ...-model-routes-openai-codex-supplemental.test.ts | 44 +++++++++++++++++++++-
 .../src/__tests__/routes-automation.test.ts        |  4 +-
 .../dashboard/src/__tests__/routes-tasks.test.ts   |  8 ++--
 packages/dashboard/src/agent-generation.ts         |  9 +++--
 packages/dashboard/src/agent-onboarding.ts         |  8 ++--
 packages/dashboard/src/routes.ts                   | 14 ++++++-
 .../src/routes/register-chat-room-routes.ts        |  2 +-
 .../dashboard/src/routes/register-model-routes.ts  | 43 ++++++++++++++++-----
 packages/i18n/locales/en/app.json                  |  1 +
 packages/i18n/locales/es/app.json                  |  1 +
 packages/i18n/locales/fr/app.json                  |  1 +
 packages/i18n/locales/ko/app.json                  |  1 +
 packages/i18n/locales/pt-BR/app.json               |  1 +
 packages/i18n/locales/zh-CN/app.json               |  1 +
 packages/i18n/locales/zh-TW/app.json               |  1 +
 packages/i18n/src/resources.d.ts                   |  1 +
 pnpm-lock.yaml                                     |  6 +--
 45 files changed, 289 insertions(+), 84 deletions(-)

Fusion-Task-Id: FN-021
Fusion-Task-Lineage: af1b0e09-6375-4f0c-9501-66a10c8b9168
Co-authored-by: Fusion <noreply@runfusion.ai>
2026-08-20 03:55:38 +00:00

359 lines
16 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { THINKING_LEVELS } from "@fusion/core";
import { Bot, Brain } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import type { ModelInfo } from "../api";
import { FN_AGENT_ID } from "../hooks/useChat";
import { isInsidePortaledModelMenu } from "../utils/portalSurfaces";
/*
FNXC:Chat-ThinkingLevel 2026-08-18-23:38:
FN-7775 only let a user pick a direct chat session's thinking (reasoning-effort) level once, at
session creation, via the New Chat dialog's model-mode picker (CustomModelDropdown's inline
selector). FN-7898 closes that gap with a small `Brain`-icon trigger next to the composer's
attach button that opens a popup listing the canonical THINKING_LEVELS plus a "Default" (clear/inherit)
option; selecting one persists immediately via PATCH /api/chat/sessions/:id and takes effect on
the session's next send. This mirrors ThemeDropdown.tsx's small-popover interaction pattern
(rootRef + pointerdown outside-close, Escape, aria-haspopup listbox) and reuses
CustomModelDropdown's exact i18n keys for level labels and the default entry, rather than
introducing a parallel thinking-level list.
FNXC:Chat-ThinkingLevel 2026-07-12-20:08:
The Default entry must describe the resolved project/global default supplied by ChatView, while omitted props preserve the legacy isolated fallback label `Default (off)`.
FNXC:Chat-ModelSwitch 2026-07-12-00:00:
The same brain-icon popup now owns active direct-session targeting too: model-loop sessions can switch provider/model via CustomModelDropdown, and agent sessions can switch to a real agent from the existing list. Selecting either closes the popup and persists immediately through useChat.setSessionModel, while CLI composers stay gated in ChatView.
FNXC:Chat-ThinkingLevel 2026-07-16-00:34:
FN-8030 lets room composers reuse this control with showTargetSection={false}. A room's thinking effort is the default reasoning effort for every responder, and rooms have no per-composer model or agent target to switch.
*/
export interface ChatThinkingLevelControlAgent {
id: string;
name: string;
role?: string;
}
export interface ChatThinkingLevelControlProps {
/** Session's current thinkingLevel; null/undefined/empty means "inherit default". */
level: string | null | undefined;
/** Called with the newly selected level ("" for the Default/clear option). */
onChange: (level: string) => void | Promise<void>;
/** Resolved project/global default used only for the Default/clear label. */
defaultThinkingLevel?: string;
/** Show direct-chat model/agent targeting controls; rooms render only the thinking-level list. */
showTargetSection?: boolean;
models?: ModelInfo[];
favoriteProviders?: string[];
favoriteModels?: string[];
agents?: ChatThinkingLevelControlAgent[];
agentId?: string | null;
modelProvider?: string | null;
modelId?: string | null;
onChangeModel?: (selection: { agentId?: string; modelProvider?: string | null; modelId?: string | null }) => void | Promise<void>;
disabled?: boolean;
}
type TargetMode = "model" | "agent";
export function ChatThinkingLevelControl({
level,
onChange,
defaultThinkingLevel = "off",
showTargetSection = true,
models = [],
favoriteProviders = [],
favoriteModels = [],
agents = [],
agentId,
modelProvider,
modelId,
onChangeModel,
disabled = false,
}: ChatThinkingLevelControlProps) {
const { t } = useTranslation("app");
const [open, setOpen] = useState(false);
const [targetMode, setTargetMode] = useState<TargetMode>(() => (agentId && agentId !== FN_AGENT_ID ? "agent" : "model"));
const rootRef = useRef<HTMLDivElement | null>(null);
const normalizedLevel = level ?? "";
const currentModelValue = modelProvider && modelId ? `${modelProvider}/${modelId}` : "";
const selectedAgentId = agentId && agentId !== FN_AGENT_ID ? agentId : "";
const selectedModel = useMemo(() => {
if (!showTargetSection || selectedAgentId || !currentModelValue) return undefined;
const slashIdx = currentModelValue.indexOf("/");
return models.find((model) => model.provider === currentModelValue.slice(0, slashIdx) && model.id === currentModelValue.slice(slashIdx + 1));
}, [currentModelValue, models, selectedAgentId, showTargetSection]);
const thinkingLevelOptions = useMemo(() => ["", ...(selectedModel?.supportedThinkingLevels ?? THINKING_LEVELS)], [selectedModel]);
const hasStaleThinkingLevel = Boolean(normalizedLevel) && !thinkingLevelOptions.includes(normalizedLevel);
const isActive = normalizedLevel !== "" || (showTargetSection && (Boolean(currentModelValue) || Boolean(selectedAgentId)));
const listboxId = "chat-thinking-level-listbox";
useEffect(() => {
if (!open) return;
const handleOutsidePress = (event: PointerEvent | TouchEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
/*
FNXC:Chat-ModelSwitch 2026-07-12-22:35:
FN-7916: CustomModelDropdown renders its option list in a document.body portal outside rootRef. Treat that portaled menu as inside this popup so tablet/touch pointerdown does not dismiss the brain popup before the option onClick can persist the model selection.
FNXC:ModelDropdown 2026-08-15-12:27:
Use the shared portal predicate for pointer and touch origins. Mobile outside-close handlers can receive touchstart before a re-anchored menu's synthesized click lands on the popup backdrop.
*/
const clickedInsideRoot = rootRef.current?.contains(target);
if (!clickedInsideRoot && !isInsidePortaledModelMenu(target)) {
setOpen(false);
}
};
document.addEventListener("pointerdown", handleOutsidePress);
document.addEventListener("touchstart", handleOutsidePress);
return () => {
document.removeEventListener("pointerdown", handleOutsidePress);
document.removeEventListener("touchstart", handleOutsidePress);
};
}, [open]);
// Close the popup whenever the underlying level or target changes out from under us
// (e.g. the active session switched) so it never leaks open across a
// session switch showing the previous session's options.
useEffect(() => {
setOpen(false);
setTargetMode(selectedAgentId ? "agent" : "model");
}, [normalizedLevel, selectedAgentId, currentModelValue]);
const selectedAgent = useMemo(
() => agents.find((agent) => agent.id === selectedAgentId),
[agents, selectedAgentId],
);
const optionLabel = (value: string): string => {
if (value === "") {
return t("modelSelection.thinkingDefault", "Default ({{level}})", { level: defaultThinkingLevel ?? "off" });
}
return t(`models.options.${value}`, value === "xhigh" ? "Very High" : value.charAt(0).toUpperCase() + value.slice(1));
};
const chooseLevel = (value: string) => {
setOpen(false);
void onChange(value);
};
const chooseModel = (value: string) => {
const slashIdx = value.indexOf("/");
if (slashIdx <= 0) return;
setOpen(false);
void onChangeModel?.({ modelProvider: value.slice(0, slashIdx), modelId: value.slice(slashIdx + 1) });
};
const chooseAgent = (nextAgentId: string) => {
if (!nextAgentId) return;
setOpen(false);
void onChangeModel?.({ agentId: nextAgentId });
};
/*
FNXC:Chat-ModelSwitch 2026-07-24-00:00:
Windows Electron can show the Agent toggle's pressed feedback after primary pointerdown while
its host prevents the following click. Commit the visual mode switch on primary pointerdown so
the available-agent list deterministically replaces the model picker; preserve click handling
only for keyboard/synthetic activation (`detail === 0`) so one pointer gesture does not reset
the local mode twice. `aria-pressed` makes the selected target observable to assistive tech.
*/
const activateTargetMode = (mode: TargetMode) => {
setTargetMode(mode);
};
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "Escape") {
setOpen(false);
}
};
const handleOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, value: string) => {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
chooseLevel(value);
}
};
const handleAgentKeyDown = (event: KeyboardEvent<HTMLButtonElement>, nextAgentId: string) => {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
chooseAgent(nextAgentId);
}
};
return (
<div className="chat-thinking-level-root" ref={rootRef}>
<button
type="button"
className={`btn-icon chat-thinking-btn${isActive ? " chat-thinking-btn--active" : ""}`}
data-testid="chat-thinking-btn"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={listboxId}
aria-label={t("chat.thinkingLevelButton", "Thinking level")}
title={t("chat.thinkingLevelButton", "Thinking level")}
disabled={disabled}
onClick={() => setOpen((value) => !value)}
onKeyDown={handleTriggerKeyDown}
>
<Brain size={16} />
</button>
{open ? (
<div className="chat-thinking-popover" role="presentation" data-testid="chat-thinking-popover">
{showTargetSection ? (
<section className="chat-thinking-target-section" aria-label={t("chat.modelAgentSection", "Model / Agent")}>
<div className="chat-thinking-section-title">{t("chat.modelAgentSection", "Model / Agent")}</div>
<div className="chat-thinking-mode-toggle" data-testid="chat-thinking-mode-toggle">
<button
type="button"
className={`chat-thinking-mode-btn${targetMode === "model" ? " chat-thinking-mode-btn--active" : ""}`}
data-testid="chat-thinking-mode-model"
aria-pressed={targetMode === "model"}
onPointerDown={(event) => {
if (event.button === 0) activateTargetMode("model");
}}
onClick={(event) => {
if (event.detail === 0) activateTargetMode("model");
}}
>
{t("chat.newChatModeModel", "Model")}
</button>
<button
type="button"
className={`chat-thinking-mode-btn${targetMode === "agent" ? " chat-thinking-mode-btn--active" : ""}`}
data-testid="chat-thinking-mode-agent"
aria-pressed={targetMode === "agent"}
onPointerDown={(event) => {
if (event.button === 0) activateTargetMode("agent");
}}
onClick={(event) => {
if (event.detail === 0) activateTargetMode("agent");
}}
>
{t("chat.newChatModeAgent", "Agent")}
</button>
</div>
{targetMode === "model" ? (
<div className="chat-thinking-model-picker" data-testid="chat-thinking-model-picker">
<CustomModelDropdown
models={models}
value={currentModelValue}
onChange={chooseModel}
label={t("chat.newChatModeModel", "Model")}
placeholder={t("chat.selectModel", "Select a model")}
disabled={!onChangeModel || models.length === 0}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
menuWidth="readable"
/>
{models.length === 0 ? (
<div className="chat-thinking-empty" data-testid="chat-thinking-model-empty">
{t("chat.noModelsAvailable", "No models available")}
</div>
) : null}
</div>
) : (
<div className="chat-thinking-agent-list" data-testid="chat-thinking-agent-list">
{agents.length === 0 ? (
<div className="chat-thinking-empty" data-testid="chat-thinking-agent-empty">
{t("chat.noAgentsAvailable", "No agents available")}
</div>
) : (
agents.map((agent) => {
const selected = selectedAgentId === agent.id;
return (
<button
key={agent.id}
type="button"
className={`chat-thinking-agent-item${selected ? " chat-thinking-agent-item--selected" : ""}`}
data-testid={`chat-thinking-agent-${agent.id}`}
aria-pressed={selected}
disabled={!onChangeModel}
onClick={() => chooseAgent(agent.id)}
onKeyDown={(event) => handleAgentKeyDown(event, agent.id)}
>
<Bot size={16} />
<span className="chat-thinking-agent-name">{agent.name || agent.id}</span>
{agent.role ? <span className="chat-thinking-agent-role">{agent.role}</span> : null}
</button>
);
})
)}
</div>
)}
{selectedAgent ? (
<div className="chat-thinking-current-target" data-testid="chat-thinking-current-agent">
{t("chat.currentAgentTarget", "Current agent: {{name}}", { name: selectedAgent.name || selectedAgent.id })}
</div>
) : currentModelValue ? (
<div className="chat-thinking-current-target" data-testid="chat-thinking-current-model">
{t("chat.currentModelTarget", "Current model: {{model}}", { model: currentModelValue })}
</div>
) : (
<div className="chat-thinking-current-target" data-testid="chat-thinking-current-default">
{t("chat.currentDefaultTarget", "Using the default chat target")}
</div>
)}
</section>
) : null}
<section className="chat-thinking-level-section" aria-label={t("chat.thinkingLevelButton", "Thinking level")}>
<div className="chat-thinking-section-title">{t("chat.thinkingLevelSection", "Thinking level")}</div>
<div
id={listboxId}
className="chat-thinking-popover-list"
role="listbox"
aria-label={t("chat.thinkingLevelButton", "Thinking level")}
>
{hasStaleThinkingLevel ? (
<button
type="button"
role="option"
aria-selected
disabled
className="chat-thinking-popover-option"
data-testid={`chat-thinking-option-${normalizedLevel}`}
>
{t("models.options.unavailable", "Unavailable: {{level}}", { level: normalizedLevel })}
</button>
) : null}
{thinkingLevelOptions.map((value) => {
const selected = normalizedLevel === value;
return (
<button
key={value || "default"}
type="button"
role="option"
aria-selected={selected}
className={`chat-thinking-popover-option${selected ? " active" : ""}`}
data-testid={`chat-thinking-option-${value || "default"}`}
onClick={() => chooseLevel(value)}
onKeyDown={(event) => handleOptionKeyDown(event, value)}
>
{optionLabel(value)}
</button>
);
})}
</div>
</section>
</div>
) : null}
</div>
);
}