FN-200: reuse unified chat model and keep thinking popover open
Reuse the unified Direct Chat model and thinking-level popover behavior across Task Chat surfaces. - Share the unified model selector and thinking control with Task Chat. - Keep the thinking popover open while selecting a model and align responsive styling. - Update documentation and regression coverage for the shared behavior. Files changed: .../fn-200-unified-chat-model-thinking-popover.md | 7 + docs/dashboard-guide.md | 8 +- .../app/components/ChatThinkingLevelControl.tsx | 172 +++++++++++++++------ packages/dashboard/app/components/ChatView.tsx | 1 + .../app/components/TaskPlannerChatTab.css | 50 ++---- .../app/components/TaskPlannerChatTab.tsx | 47 +++--- .../ChatThinkingLevelControl.portal.test.tsx | 5 +- .../__tests__/ChatThinkingLevelControl.test.tsx | 168 ++++++++++++++++++-- .../__tests__/ChatView.thinking-level.test.tsx | 4 + ...etailModal.responsive-and-dependencies.test.tsx | 9 +- .../__tests__/TaskPlannerChatTab.test.tsx | 86 ++++++++++- 11 files changed, 420 insertions(+), 137 deletions(-) Fusion-Task-Id: FN-200 Fusion-Task-Lineage: 16e9fbc7-89bf-4492-bed3-c30cdbf85393 Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-200-unified-chat-model-thinking-popover.md
Normal file
7
.changeset/fn-200-unified-chat-model-thinking-popover.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Unify Task Chat model and thinking controls in one Brain popover.
|
||||
category: feature
|
||||
dev: ChatThinkingLevelControl now supports model-only targeting, picker labels, target identity, default targets, and echo-safe dismissal.
|
||||
@@ -276,7 +276,7 @@ On mobile viewports, the Right Dock never renders. The compact Header actions an
|
||||
|
||||
## Task-detail Chat
|
||||
|
||||
Task-detail **Chat** uses the project’s configured Direct Chat default model and thinking level rather than the task’s planning model. It remains task-aware: the server builds the task definition, dependencies, activity, metrics, steering, and refinement context, and the existing `task-planner:<taskId>` session keeps one transcript per task. Task context always comes from the selected project’s authoritative store, including while that project’s engine has not started or is unavailable; another project’s task with the same ID cannot supply its context. The composer exposes the same model and thinking controls as Direct Chat; its compact model trigger opens the same readable, viewport-clamped menu on desktop and mobile. Model choices remain model-targeted and do not replace the synthetic task-scoped permission contract. Changing the project default does not hide history, and the next explicit send applies the current target to the existing idle session. There is no separate planner-model lane for this conversation.
|
||||
Task-detail **Chat** uses the project’s configured Direct Chat default model and thinking level rather than the task’s planning model. It remains task-aware: the server builds the task definition, dependencies, activity, metrics, steering, and refinement context, and the existing `task-planner:<taskId>` session keeps one transcript per task. Task context always comes from the selected project’s authoritative store, including while that project’s engine has not started or is unavailable; another project’s task with the same ID cannot supply its context. Its single **Brain** popover holds the model picker and thinking-level list in the same readable, viewport-clamped control on desktop and mobile. Task Chat remains model-only, so choices do not replace the synthetic task-scoped permission contract with a Direct Chat agent. Selecting a model keeps the popover open for a thinking-level choice; selecting a thinking level, switching conversations, or receiving a target change that was not selected closes it. Changing the project default does not hide history, and the next explicit send applies the current target to the existing idle session. There is no separate planner-model lane for this conversation.
|
||||
|
||||
## Chat message editing and rewind
|
||||
|
||||
@@ -806,7 +806,7 @@ Mailbox Inbox, Outbox, and agent lists exclude archived correspondence and unrea
|
||||
<!-- FNXC:Chat-ModelSwitch 2026-07-12-00:00: FN-7908 extends that same Brain popup rather than adding another composer button, so active non-CLI Direct chats can retarget to a model pair or real agent mid-conversation and the next send resolves the updated session target. -->
|
||||
<!-- FNXC:Chat-ModelSwitch 2026-07-12-22:44: FN-7916 keeps the Brain popup usable on mobile/tablet touch devices: the portaled shared model picker is treated as part of the popup for selection, and the popup is viewport-fitted instead of anchored off-screen. -->
|
||||
<!-- FNXC:Chat-ModelSwitch 2026-07-13-00:00: FN-7934 applies that fitted Brain-popup layout to narrow chat surfaces, including floating Chat windows and compact docks on wide desktop viewports, because the browser viewport alone does not describe the popover's clipping container. -->
|
||||
- A small **Brain**-icon button next to the composer's attach button lets you change an already-created direct chat session's target and thinking level mid-conversation, without starting a new chat. Its **Model / Agent** section can switch the session to another model via the shared model picker or to a real agent from the agent list; its **Thinking level** section still lists the six thinking levels plus **Default** (clear/inherit, labeled with the current resolved default such as **Default (medium)**). Each selection persists immediately and applies starting with the session's next send, including on mobile/tablet touch viewports and narrow floating Chat windows or compact docks where the popup stays fitted to the chat surface. This control appears only for non-CLI Direct sessions — it is not shown for CLI-agent-backed sessions or in Chat Rooms, neither of which support this per-session retargeting control.
|
||||
- A small **Brain**-icon button next to the composer's attach button lets you change an already-created direct chat session's target and thinking level mid-conversation, without starting a new chat. Its **Model / Agent** section can switch the session to another model via the shared model picker or to a real agent from the agent list; its **Thinking level** section still lists the six thinking levels plus **Default** (clear/inherit, labeled with the current resolved default such as **Default (medium)**). Selecting a model or agent keeps the popover open so you can choose a thinking level in the same interaction. Selecting a thinking level deliberately closes it; switching to another conversation or receiving a target change that did not echo your selection also closes it to avoid stale options. Each selection persists immediately and applies starting with the session's next send, including on mobile/tablet touch viewports and narrow floating Chat windows or compact docks where the popup stays fitted to the chat surface. This control appears only for non-CLI Direct sessions — it is not shown for CLI-agent-backed sessions or in Chat Rooms, neither of which support this per-session retargeting control.
|
||||
- Full Chat and Quick Chat both consume the same streamed `/api/chat/sessions/:id/messages` response contract, and both now prefer the authoritative assistant `message` snapshot on `done` while still accumulating `text` chunks when present (so providers without incremental text streaming still render output immediately)
|
||||
<!-- FNXC:ChatCancellation 2026-08-19-05:20: Direct/Quick Chat and task Planner Chat must make an explicit Stop durable for reload and the next model turn, while Chat Rooms and CLI-agent-backed sessions retain their separate cancellation semantics. -->
|
||||
- Stopping a Direct/Quick Chat or task Chat model-loop response retains any non-empty text already streamed as one interrupted assistant conversation message, including after refresh/remount and in the next turn's file-backed model context. Chat Rooms and CLI-agent-backed chat sessions are excluded from this model-loop continuity contract.
|
||||
@@ -1618,7 +1618,7 @@ For related global/project configuration behavior, see [Settings reference](./se
|
||||
Inspect task definition, logs, review feedback, comments, artifacts, workflow outcomes, model overrides, and task routing from a single modal.
|
||||
|
||||
- Editable tasks with descriptions show **Summarize as title** beside the read-mode title; it asks AI to generate a concise title from the description and saves it without opening the edit form.
|
||||
- The top-level **Chat** tab appears first for active task details and is the default landing tab for non-`done` tasks. It uses the project Direct Chat default model and thinking level, and exposes reusable model/thinking selectors without impersonating a Direct Chat agent. Opening the tab is lookup-only: Fusion creates the task-scoped Chat session only after you send a composer message, starter prompt, or question answer. Once a user message exists, the resumable planner chat can appear in the global Chat list; interacted chats are kept when the task reaches `done` and removed when the task is archived. Each send includes bounded server-built task context so the planner can answer current status, progress, recent activity, dependency, and task definition questions. It shows starter prompts for common planning questions, can render structured planner questions, and converts only explicit operator steering intent through the scoped steering tool. The composer stays pinned while the transcript, loading, error, starter, history, and streaming states scroll internally; on mobile/narrow task detail, the default focused Chat layout hides nonessential title/metadata/tab/action rows until you collapse it from the in-view expand control.
|
||||
- The top-level **Chat** tab appears first for active task details and is the default landing tab for non-`done` tasks. It uses the project Direct Chat default model and thinking level, and exposes one Brain popover with model-only targeting and thinking-level selection without impersonating a Direct Chat agent. Opening the tab is lookup-only: Fusion creates the task-scoped Chat session only after you send a composer message, starter prompt, or question answer. Once a user message exists, the resumable planner chat can appear in the global Chat list; interacted chats are kept when the task reaches `done` and removed when the task is archived. Each send includes bounded server-built task context so the planner can answer current status, progress, recent activity, dependency, and task definition questions. It shows starter prompts for common planning questions, can render structured planner questions, and converts only explicit operator steering intent through the scoped steering tool. The composer stays pinned while the transcript, loading, error, starter, history, and streaming states scroll internally; on mobile/narrow task detail, the default focused Chat layout hides nonessential title/metadata/tab/action rows until you collapse it from the in-view expand control.
|
||||
- The **Activity → Live**, **Feed**, and **Raw Logs** segments remain immediately after **Chat** and share an expand/collapse control that lets the active Activity segment fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed.
|
||||
- The **Summary** tab appears for `done` tasks and remains their default landing tab. It shows the recorded completion summary, the **Merge Details** card (merge status, commit, PR, timestamp, and message), changed-file/merge stats when available, completed steps, workflow results, retry counts, and a token usage & cost section broken down by model from the already-loaded task detail; unpriced models show cost as unavailable rather than `$0`.
|
||||
<!-- FNXC:TaskRecommendations 2026-08-19-13:05: Task Detail docs distinguish optional capture from the project opt-in that requires an explicit quality-first evaluation without turning the cap into a quota. -->
|
||||
@@ -1718,7 +1718,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig
|
||||
<!-- FNXC:TaskDetailActivity 2026-07-04-19:10: FN-7536: the opening tap that shows the Activity views menu can itself trigger a same-gesture window resize/scroll echo (Android/mobile Chrome URL-bar collapse or tap-into-view auto-scroll, distinct from the iOS visualViewport echo above). That echo, and scrolling the `.detail-tabs` horizontal tab strip itself, now only reposition the open menu instead of closing it; a later, real viewport change still closes it as before. -->
|
||||
The **Activity** tab is the first task-detail tab by default and presents **Live**, **Feed**, and **Raw Logs** as a segmented control on wider layouts and as a fixed, root-portaled **Activity views** dropdown on narrow mobile layouts. The dropdown stays above its owning task-detail modal or task popup and follows the Activity tab while a popup is dragged or resized. Live contains the live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Agent group headers and user message headers show a small muted relative timestamp (for example, “just now”, “1m ago”, or “2h ago”) based on the transcript timestamp, while agent group metadata still includes the entry count. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default and mirrors regular Chat's dense treatment; the summary stays single-line/ellipsis-friendly on desktop and mobile, counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded for `in-progress` and `in-review` tasks so active reasoning is visible at a glance; blocks start collapsed for other task columns and remain user-toggleable in every state. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When older task-agent history exists, scrolling to the top or selecting **Load previous messages** prepends earlier transcript entries without moving the message you were reading. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the Activity Live composer sends typed guidance through the same steering path used by comments, including active planning/triage, `in-progress`, and `in-review` sessions, plus live CLI-agent sessions reported by the session bridge; an `in-review` Activity Live message or Comments-tab task comment re-engages an executor unless an open PR blocks moving the task back, and other messages are still saved as queued guidance when no session is currently live. Feed and Raw Logs do not show the composer. On a `done` task, the same composer starts a refinement task using the typed text as feedback and shows a success toast with the new task ID, while the current task detail modal remains on the completed task. The task-detail Activity Live segment keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint. In the composer, plain **Enter** sends, **Shift+Enter** inserts a newline, and **Cmd/Ctrl+Enter** remains a supported send shortcut.
|
||||
|
||||
The top-level **Chat** tab opens a task-aware Chat conversation for the same task instead of posting steering comments. It targets the project Direct Chat default model and thinking level, with the same model and thinking selectors available in general Chat; it retains the synthetic `task-planner:<taskId>` session so server-built task context and scoped tools remain intact. It appears after Activity by default, or before Activity when **Settings → Appearance → Open task details with Chat first** is enabled. Each send includes server-built, bounded context for the task id, status/column/progress/current step, dependencies, recent activity/comment excerpts, prompt/plan content, and available source/review state; unavailable sections are labeled so the planner states uncertainty rather than inventing execution evidence. Opening the tab with no existing history does not create a database chat row; when no task-Chat history is found, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review. Selecting a starter creates/resumes the task Chat session and sends that prompt as an ordinary chat message through the task-context-aware task-Chat composer/stream path, including for completed tasks. On live tasks, clear bounded implementation-change requests are routed to task steering; on `done` tasks, clear follow-up implementation or improvement requests are routed through a task-scoped planner refinement tool that calls the same refinement creation path as the completed-task Activity composer. The starter prompts disappear while history is loading or after conversation history exists, so Activity Live, Feed, Raw Logs, and the steering/refinement composer remain separate. Task Chat uses the same standard chat bubble, markdown/plain assistant rendering, thinking details, tool-call/question cards, and mobile first-tap send/stop affordance as the main Chat view while keeping task-scoped planner sessions separate. Planner Chat defaults to focused mode, keeps its composer visible at the bottom while only the transcript scrolls, and on narrow/mobile task-detail layouts collapses nonessential rows above the chat until the user selects the Chat collapse control.
|
||||
The top-level **Chat** tab opens a task-aware Chat conversation for the same task instead of posting steering comments. It targets the project Direct Chat default model and thinking level through one Brain popover with model-only targeting; it retains the synthetic `task-planner:<taskId>` session so server-built task context and scoped tools remain intact. It appears after Activity by default, or before Activity when **Settings → Appearance → Open task details with Chat first** is enabled. Each send includes server-built, bounded context for the task id, status/column/progress/current step, dependencies, recent activity/comment excerpts, prompt/plan content, and available source/review state; unavailable sections are labeled so the planner states uncertainty rather than inventing execution evidence. Opening the tab with no existing history does not create a database chat row; when no task-Chat history is found, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review. Selecting a starter creates/resumes the task Chat session and sends that prompt as an ordinary chat message through the task-context-aware task-Chat composer/stream path, including for completed tasks. On live tasks, clear bounded implementation-change requests are routed to task steering; on `done` tasks, clear follow-up implementation or improvement requests are routed through a task-scoped planner refinement tool that calls the same refinement creation path as the completed-task Activity composer. The starter prompts disappear while history is loading or after conversation history exists, so Activity Live, Feed, Raw Logs, and the steering/refinement composer remain separate. Task Chat uses the same standard chat bubble, markdown/plain assistant rendering, thinking details, tool-call/question cards, and mobile first-tap send/stop affordance as the main Chat view while keeping task-scoped planner sessions separate. Planner Chat defaults to focused mode, keeps its composer visible at the bottom while only the transcript scrolls, and on narrow/mobile task-detail layouts collapses nonessential rows above the chat until the user selects the Chat collapse control.
|
||||
|
||||
The **Raw Logs** segment is designed for debugging long-running and tool-heavy sessions, while legacy links that requested the former top-level Logs tab land on Activity → Feed:
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ 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-ModelSwitch 2026-08-27-12:03:
|
||||
Task Chat reuses this one brain-icon popover with model-only targeting, so selecting its model can never impersonate a durable agent. Direct Chat retains the agent lane; hosts that opt out of it render only the model picker and the shared thinking-level list.
|
||||
|
||||
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.
|
||||
@@ -44,6 +44,16 @@ export interface ChatThinkingLevelControlProps {
|
||||
defaultThinkingLevel?: string;
|
||||
/** Show direct-chat model/agent targeting controls; rooms render only the thinking-level list. */
|
||||
showTargetSection?: boolean;
|
||||
/** Keep the direct-chat agent lane visible; model-only hosts never render agent controls. */
|
||||
showAgentTarget?: boolean;
|
||||
/** Optional accessible label forwarded to the embedded model picker. */
|
||||
modelPickerLabel?: string;
|
||||
/** Optional inherited/default entry label forwarded to the embedded model picker. */
|
||||
modelDefaultOptionLabel?: string;
|
||||
/** Conversation identity; omitted and null are both a stable legacy identity. */
|
||||
targetKey?: string | null;
|
||||
/** Concrete target that this host applies when the picker chooses its default entry. */
|
||||
defaultModelValue?: string;
|
||||
models?: ModelInfo[];
|
||||
favoriteProviders?: string[];
|
||||
favoriteModels?: string[];
|
||||
@@ -57,11 +67,19 @@ export interface ChatThinkingLevelControlProps {
|
||||
|
||||
type TargetMode = "model" | "agent";
|
||||
|
||||
type TargetExpectation = { agent: string; model: string };
|
||||
type TargetSnapshot = TargetExpectation & { key: string | null; level: string };
|
||||
|
||||
export function ChatThinkingLevelControl({
|
||||
level,
|
||||
onChange,
|
||||
defaultThinkingLevel = "off",
|
||||
showTargetSection = true,
|
||||
showAgentTarget = true,
|
||||
modelPickerLabel,
|
||||
modelDefaultOptionLabel,
|
||||
targetKey,
|
||||
defaultModelValue,
|
||||
models = [],
|
||||
favoriteProviders = [],
|
||||
favoriteModels = [],
|
||||
@@ -74,11 +92,19 @@ export function ChatThinkingLevelControl({
|
||||
}: ChatThinkingLevelControlProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [targetMode, setTargetMode] = useState<TargetMode>(() => (agentId && agentId !== FN_AGENT_ID ? "agent" : "model"));
|
||||
const [targetMode, setTargetMode] = useState<TargetMode>(() => (showAgentTarget && 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 normalizedTargetKey = targetKey ?? null;
|
||||
const pendingTargetRef = useRef<TargetExpectation | null>(null);
|
||||
const lastTargetSnapshotRef = useRef<TargetSnapshot>({
|
||||
key: normalizedTargetKey,
|
||||
level: normalizedLevel,
|
||||
agent: selectedAgentId,
|
||||
model: currentModelValue,
|
||||
});
|
||||
const selectedModel = useMemo(() => {
|
||||
if (!showTargetSection || selectedAgentId || !currentModelValue) return undefined;
|
||||
const slashIdx = currentModelValue.indexOf("/");
|
||||
@@ -103,6 +129,7 @@ export function ChatThinkingLevelControl({
|
||||
*/
|
||||
const clickedInsideRoot = rootRef.current?.contains(target);
|
||||
if (!clickedInsideRoot && !isInsidePortaledModelMenu(target)) {
|
||||
pendingTargetRef.current = null;
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
@@ -114,13 +141,43 @@ export function ChatThinkingLevelControl({
|
||||
};
|
||||
}, [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.
|
||||
/*
|
||||
FNXC:Chat-ModelSwitch 2026-08-27-12:03:
|
||||
Drop stale options when a host changes the conversation or target underneath an open popover.
|
||||
Conversation identity is structural rather than inferred from target values because another
|
||||
conversation can carry the exact target just selected. Within one identity, only an exact,
|
||||
single-use target echo remains open; every other prop change closes it. Thinking-level picks
|
||||
are the only deliberate selection that dismisses the popover.
|
||||
*/
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
setTargetMode(selectedAgentId ? "agent" : "model");
|
||||
}, [normalizedLevel, selectedAgentId, currentModelValue]);
|
||||
const previous = lastTargetSnapshotRef.current;
|
||||
const next: TargetSnapshot = {
|
||||
key: normalizedTargetKey,
|
||||
level: normalizedLevel,
|
||||
agent: selectedAgentId,
|
||||
model: currentModelValue,
|
||||
};
|
||||
const targetKeyMoved = previous.key !== next.key;
|
||||
const targetMoved = previous.agent !== next.agent || previous.model !== next.model;
|
||||
const levelMoved = previous.level !== next.level;
|
||||
|
||||
if (targetKeyMoved) {
|
||||
pendingTargetRef.current = null;
|
||||
setOpen(false);
|
||||
} else if (targetMoved) {
|
||||
const pending = pendingTargetRef.current;
|
||||
pendingTargetRef.current = null;
|
||||
if (!pending || pending.agent !== next.agent || pending.model !== next.model) {
|
||||
setOpen(false);
|
||||
}
|
||||
} else if (levelMoved) {
|
||||
pendingTargetRef.current = null;
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
lastTargetSnapshotRef.current = next;
|
||||
setTargetMode(showAgentTarget && selectedAgentId ? "agent" : "model");
|
||||
}, [currentModelValue, normalizedLevel, normalizedTargetKey, selectedAgentId, showAgentTarget]);
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
() => agents.find((agent) => agent.id === selectedAgentId),
|
||||
@@ -135,20 +192,28 @@ export function ChatThinkingLevelControl({
|
||||
};
|
||||
|
||||
const chooseLevel = (value: string) => {
|
||||
pendingTargetRef.current = null;
|
||||
setOpen(false);
|
||||
void onChange(value);
|
||||
};
|
||||
|
||||
const armTargetExpectation = (candidate: TargetExpectation) => {
|
||||
if (!onChangeModel || (candidate.agent === selectedAgentId && candidate.model === currentModelValue)) return;
|
||||
pendingTargetRef.current = candidate;
|
||||
};
|
||||
|
||||
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) });
|
||||
if (value !== "" && (slashIdx <= 0 || slashIdx === value.length - 1)) return;
|
||||
armTargetExpectation({ agent: "", model: value === "" ? defaultModelValue ?? "" : value });
|
||||
void onChangeModel?.(value === ""
|
||||
? { modelProvider: null, modelId: null }
|
||||
: { modelProvider: value.slice(0, slashIdx), modelId: value.slice(slashIdx + 1) });
|
||||
};
|
||||
|
||||
const chooseAgent = (nextAgentId: string) => {
|
||||
if (!nextAgentId) return;
|
||||
setOpen(false);
|
||||
armTargetExpectation({ agent: nextAgentId, model: "" });
|
||||
void onChangeModel?.({ agentId: nextAgentId });
|
||||
};
|
||||
|
||||
@@ -166,6 +231,7 @@ export function ChatThinkingLevelControl({
|
||||
|
||||
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === "Escape") {
|
||||
pendingTargetRef.current = null;
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
@@ -173,6 +239,7 @@ export function ChatThinkingLevelControl({
|
||||
const handleOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, value: string) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
pendingTargetRef.current = null;
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
@@ -185,6 +252,7 @@ export function ChatThinkingLevelControl({
|
||||
const handleAgentKeyDown = (event: KeyboardEvent<HTMLButtonElement>, nextAgentId: string) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
pendingTargetRef.current = null;
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
@@ -206,7 +274,10 @@ export function ChatThinkingLevelControl({
|
||||
aria-label={t("chat.thinkingLevelButton", "Thinking level")}
|
||||
title={t("chat.thinkingLevelButton", "Thinking level")}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
onClick={() => setOpen((value) => {
|
||||
if (value) pendingTargetRef.current = null;
|
||||
return !value;
|
||||
})}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
>
|
||||
<Brain size={16} />
|
||||
@@ -215,47 +286,50 @@ export function ChatThinkingLevelControl({
|
||||
{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>
|
||||
<section className="chat-thinking-target-section" aria-label={showAgentTarget ? t("chat.modelAgentSection", "Model / Agent") : t("chat.newChatModeModel", "Model")}>
|
||||
<div className="chat-thinking-section-title">{showAgentTarget ? t("chat.modelAgentSection", "Model / Agent") : t("chat.newChatModeModel", "Model")}</div>
|
||||
{showAgentTarget ? (
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
{targetMode === "model" ? (
|
||||
{targetMode === "model" || !showAgentTarget ? (
|
||||
<div className="chat-thinking-model-picker" data-testid="chat-thinking-model-picker">
|
||||
<CustomModelDropdown
|
||||
models={models}
|
||||
value={currentModelValue}
|
||||
onChange={chooseModel}
|
||||
label={t("chat.newChatModeModel", "Model")}
|
||||
label={modelPickerLabel ?? t("chat.newChatModeModel", "Model")}
|
||||
placeholder={t("chat.selectModel", "Select a model")}
|
||||
defaultOptionLabel={modelDefaultOptionLabel}
|
||||
disabled={!onChangeModel || models.length === 0}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
@@ -296,7 +370,7 @@ export function ChatThinkingLevelControl({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedAgent ? (
|
||||
{showAgentTarget && 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>
|
||||
|
||||
@@ -2937,6 +2937,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
agentId={activeSession?.agentId}
|
||||
modelProvider={activeSession?.modelProvider}
|
||||
modelId={activeSession?.modelId}
|
||||
targetKey={activeSession?.id ?? null}
|
||||
onChange={(level) => {
|
||||
if (activeSession) {
|
||||
void setSessionThinkingLevel(activeSession.id, level);
|
||||
|
||||
@@ -211,29 +211,11 @@ FNXC:TaskDetailPlannerChat 2026-07-07-00:00:
|
||||
The Planner Chat streaming Stop button must occupy the same width footprint as the Send button it replaces (no shift/shrink on swap) and mirror the regular Chat view's stop-button sizing (`.chat-input-row`'s `--chat-input-control-size` in ChatView.css). The shared `.chat-input-send` / `.chat-input-stop` classes read that custom property from `.chat-input-row`, which the Planner composer never renders inside of, so the property was undefined here and `width` fell back to `auto`, sizing each button from its own content only. Declare the same control-size formula scoped to `.task-planner-chat-composer` and give `.task-planner-chat-send` (present on both the send and stop button variants) a matching `min-inline-size` floor so neither button can render narrower than the other on desktop, without touching ChatView.css's own token.
|
||||
*/
|
||||
/*
|
||||
FNXC:TaskChatDefaultModel 2026-08-19-12:12:
|
||||
Task Chat keeps model and thinking controls reachable beside the composer, reusing the Direct Chat primitives instead of adding a task-only selector style. The compact control row wraps on narrow layouts so the task transcript and send affordance remain usable.
|
||||
FNXC:TaskChatDefaultModel 2026-08-27-12:03:
|
||||
Task Chat reuses the shared brain popover for model-only targeting and thinking level, so the
|
||||
composer has one control without an agent-impersonation lane. Its positioned full-width ancestor
|
||||
keeps the wider shared popover inside the narrower task panel at every breakpoint.
|
||||
*/
|
||||
.task-planner-chat-target-controls {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-planner-chat-target-controls .model-combobox {
|
||||
min-width: 0;
|
||||
max-width: calc(var(--space-2xl) * 8);
|
||||
}
|
||||
|
||||
.task-planner-chat-target-controls .model-combobox-trigger {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.task-planner-chat-target-controls .chat-thinking-level-root {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ChatMemoryFocusSelector 2026-08-24-03:40:
|
||||
@@ -256,6 +238,7 @@ composer ancestor. The chip inherits its control-size token from the composer be
|
||||
/* FN-7634: same formula as ChatView.css's `.chat-input-row { --chat-input-control-size: … }` so the Planner stop/send buttons share the regular Chat view's control-size floor. */
|
||||
--chat-input-control-size: calc(var(--space-lg) * 2.5);
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
@@ -263,6 +246,18 @@ composer ancestor. The chip inherits its control-size token from the composer be
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-planner-chat-composer .chat-thinking-level-root {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.task-planner-chat-composer .chat-thinking-popover {
|
||||
left: var(--space-md);
|
||||
right: var(--space-md);
|
||||
width: auto;
|
||||
max-width: none;
|
||||
max-inline-size: none;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ChatComposer 2026-08-20-19:25:
|
||||
FN-076 requires Planner Chat to grow automatically through five rendered lines, internally scroll excess content, and shrink after deletion. Native mouse resizing is disabled and CSS leaves the controller's measured cap authoritative.
|
||||
@@ -317,17 +312,6 @@ height rather than relying on the global button minimum.
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.task-planner-chat-target-controls {
|
||||
flex: 1 1 100%;
|
||||
order: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.task-planner-chat-target-controls .model-combobox {
|
||||
flex: 1 1 auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.task-planner-chat-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -14,7 +14,6 @@ import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/par
|
||||
import { ChatQuestionResponse } from "./ChatQuestionResponse";
|
||||
import { PendingChatMessageQueue } from "./PendingChatMessageQueue";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl";
|
||||
import { useModelsCache } from "../hooks/useModelsCache";
|
||||
import { StandardChatActionButton, StandardChatMessageItem, StandardStreamingMessage, formatModelTag } from "./StandardChatSurface";
|
||||
@@ -1660,32 +1659,26 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
|
||||
</div>
|
||||
)}
|
||||
<div className="task-planner-chat-composer">
|
||||
<div className="task-planner-chat-target-controls" data-testid="task-planner-chat-target-controls">
|
||||
<CustomModelDropdown
|
||||
id="task-planner-chat-model-selector"
|
||||
label={t("taskDetail.plannerChat.modelLabel", "Chat model")}
|
||||
models={models}
|
||||
value={displayedModelProvider && displayedModelId ? `${displayedModelProvider}/${displayedModelId}` : ""}
|
||||
onChange={(value) => void handleTaskChatModelChange(value)}
|
||||
placeholder={t("model.selectPlaceholder", "Select a model…")}
|
||||
defaultOptionLabel={t("models.useDefault", "Use project default")}
|
||||
/*
|
||||
FNXC:TaskChatModelMenu 2026-08-21-01:12:
|
||||
Task Chat keeps its compact composer trigger, but long provider/model names need Direct Chat's readable, viewport-clamped portaled menu on desktop and mobile.
|
||||
*/
|
||||
menuWidth="readable"
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
disabled={queueActionPending || composerState === "sending"}
|
||||
/>
|
||||
<ChatThinkingLevelControl
|
||||
level={displayedModel.thinkingLevel}
|
||||
defaultThinkingLevel={taskChatModel.thinkingLevel ?? "off"}
|
||||
showTargetSection={false}
|
||||
onChange={(level) => void handleTaskChatThinkingChange(level)}
|
||||
disabled={queueActionPending || composerState === "sending"}
|
||||
/>
|
||||
</div>
|
||||
<ChatThinkingLevelControl
|
||||
level={displayedModel.thinkingLevel}
|
||||
defaultThinkingLevel={taskChatModel.thinkingLevel ?? "off"}
|
||||
showTargetSection
|
||||
showAgentTarget={false}
|
||||
targetKey={plannerChatScopeKey}
|
||||
models={models}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
modelProvider={displayedModelProvider ?? null}
|
||||
modelId={displayedModelId ?? null}
|
||||
modelPickerLabel={t("taskDetail.plannerChat.modelLabel", "Chat model")}
|
||||
modelDefaultOptionLabel={t("models.useDefault", "Use project default")}
|
||||
defaultModelValue={taskChatModel.provider && taskChatModel.modelId ? `${taskChatModel.provider}/${taskChatModel.modelId}` : ""}
|
||||
onChange={(level) => void handleTaskChatThinkingChange(level)}
|
||||
onChangeModel={(selection) => void handleTaskChatModelChange(
|
||||
selection.modelProvider && selection.modelId ? `${selection.modelProvider}/${selection.modelId}` : "",
|
||||
)}
|
||||
disabled={queueActionPending || composerState === "sending"}
|
||||
/>
|
||||
<textarea
|
||||
ref={handleComposerRef}
|
||||
className="input task-planner-chat-input"
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("ChatThinkingLevelControl with the real CustomModelDropdown portal", ()
|
||||
fireEvent.click(within(portal).getByText("GPT-4o"));
|
||||
|
||||
expect(onChangeModel).toHaveBeenCalledWith({ modelProvider: "openai", modelId: "gpt-4o" });
|
||||
await waitFor(() => expect(screen.queryByTestId("chat-thinking-popover")).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByTestId("chat-thinking-popover")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("keeps the brain popup open for touchstart inside the portaled model menu", async () => {
|
||||
@@ -69,9 +69,8 @@ describe("ChatThinkingLevelControl with the real CustomModelDropdown portal", ()
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-mode-agent"));
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-agent-agent-002"));
|
||||
expect(onChangeModel).toHaveBeenCalledWith({ agentId: "agent-002" });
|
||||
expect(screen.queryByTestId("chat-thinking-popover")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-thinking-popover")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-option-high"));
|
||||
expect(onChange).toHaveBeenCalledWith("high");
|
||||
expect(screen.queryByTestId("chat-thinking-popover")).not.toBeInTheDocument();
|
||||
|
||||
@@ -7,16 +7,34 @@ import { FN_AGENT_ID } from "../../hooks/useChat";
|
||||
import { ChatThinkingLevelControl } from "../ChatThinkingLevelControl";
|
||||
|
||||
vi.mock("../CustomModelDropdown", () => ({
|
||||
CustomModelDropdown: ({ value, onChange, disabled }: { value: string; onChange: (value: string) => void; disabled?: boolean }) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-model-dropdown"
|
||||
data-value={value}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange("openai/gpt-4o")}
|
||||
>
|
||||
{value || "Select a model"}
|
||||
</button>
|
||||
CustomModelDropdown: ({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
label,
|
||||
defaultOptionLabel,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
defaultOptionLabel?: string;
|
||||
}) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-model-dropdown"
|
||||
data-value={value}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange("openai/gpt-4o")}
|
||||
>
|
||||
{value || "Select a model"}
|
||||
</button>
|
||||
<button type="button" data-testid="mock-model-default" disabled={disabled} onClick={() => onChange("")}>{defaultOptionLabel ?? "Use default"}</button>
|
||||
<button type="button" data-testid="mock-model-malformed-provider" disabled={disabled} onClick={() => onChange("openai")}>Malformed provider</button>
|
||||
<button type="button" data-testid="mock-model-malformed-trailing" disabled={disabled} onClick={() => onChange("openai/")}>Malformed trailing slash</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -93,6 +111,51 @@ describe("ChatThinkingLevelControl", () => {
|
||||
expect(onChange).toHaveBeenCalledWith("max");
|
||||
});
|
||||
|
||||
it("renders model-only targeting without any agent controls", () => {
|
||||
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={vi.fn()} showAgentTarget={false} models={models} agents={agents} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
|
||||
expect(screen.getByRole("listbox")).toBeDefined();
|
||||
expect(screen.getByText("Model")).toBeDefined();
|
||||
expect(screen.getByTestId("chat-thinking-model-picker")).toBeDefined();
|
||||
expect(screen.queryByTestId("chat-thinking-mode-toggle")).toBeNull();
|
||||
expect(screen.queryByTestId("chat-thinking-agent-list")).toBeNull();
|
||||
expect(screen.queryByTestId("chat-thinking-agent-empty")).toBeNull();
|
||||
});
|
||||
|
||||
it("forwards model picker labels and default selection to the host", () => {
|
||||
const onChangeModel = vi.fn();
|
||||
render(
|
||||
<ChatThinkingLevelControl
|
||||
level={null}
|
||||
onChange={vi.fn()}
|
||||
onChangeModel={onChangeModel}
|
||||
models={models}
|
||||
modelPickerLabel="Chat model"
|
||||
modelDefaultOptionLabel="Use project default"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
expect(screen.getByLabelText("Chat model")).toBeDefined();
|
||||
expect(screen.getByTestId("mock-model-default")).toHaveTextContent("Use project default");
|
||||
|
||||
fireEvent.click(screen.getByTestId("mock-model-default"));
|
||||
expect(onChangeModel).toHaveBeenCalledWith({ modelProvider: null, modelId: null });
|
||||
});
|
||||
|
||||
it("ignores malformed non-empty model values", () => {
|
||||
const onChangeModel = vi.fn();
|
||||
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} models={models} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
fireEvent.click(screen.getByTestId("mock-model-malformed-provider"));
|
||||
fireEvent.click(screen.getByTestId("mock-model-malformed-trailing"));
|
||||
|
||||
expect(onChangeModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders only thinking-level options in level-only mode and persists selections", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChatThinkingLevelControl level="medium" onChange={onChange} showTargetSection={false} models={models} agents={agents} />);
|
||||
@@ -183,7 +246,7 @@ describe("ChatThinkingLevelControl", () => {
|
||||
expect(screen.getByTestId("chat-thinking-model-picker")).toBeDefined();
|
||||
});
|
||||
|
||||
it("selecting a model calls onChangeModel with the provider/model pair and closes", () => {
|
||||
it("selecting a model calls onChangeModel and keeps the popover available for thinking", () => {
|
||||
const onChangeModel = vi.fn();
|
||||
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} models={models} />);
|
||||
|
||||
@@ -191,10 +254,10 @@ describe("ChatThinkingLevelControl", () => {
|
||||
fireEvent.click(screen.getByTestId("mock-model-dropdown"));
|
||||
|
||||
expect(onChangeModel).toHaveBeenCalledWith({ modelProvider: "openai", modelId: "gpt-4o" });
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
expect(screen.getByRole("listbox")).toBeDefined();
|
||||
});
|
||||
|
||||
it("selecting an agent calls onChangeModel with agentId and closes", () => {
|
||||
it("selecting an agent calls onChangeModel and keeps the popover available for thinking", () => {
|
||||
const onChangeModel = vi.fn();
|
||||
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} agents={agents} />);
|
||||
|
||||
@@ -203,7 +266,84 @@ describe("ChatThinkingLevelControl", () => {
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-agent-agent-002"));
|
||||
|
||||
expect(onChangeModel).toHaveBeenCalledWith({ agentId: "agent-002" });
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
expect(screen.getByRole("listbox")).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps a matched model echo open and then closes on a thinking-level selection", () => {
|
||||
const onChangeModel = vi.fn();
|
||||
const { rerender } = render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} models={models} targetKey="session-a" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
fireEvent.click(screen.getByTestId("mock-model-dropdown"));
|
||||
rerender(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} models={models} targetKey="session-a" modelProvider="openai" modelId="gpt-4o" />);
|
||||
|
||||
expect(screen.getByTestId("chat-thinking-popover")).toBeDefined();
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-option-high"));
|
||||
expect(screen.queryByTestId("chat-thinking-popover")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps matched agent and default target echoes open", () => {
|
||||
const onChangeModel = vi.fn();
|
||||
const { rerender } = render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} agents={agents} targetKey="session-a" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-mode-agent"));
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-agent-agent-002"));
|
||||
rerender(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} agents={agents} targetKey="session-a" agentId="agent-002" />);
|
||||
expect(screen.getByTestId("chat-thinking-popover")).toBeDefined();
|
||||
|
||||
rerender(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} models={models} targetKey="session-a" defaultModelValue="openai/gpt-4o" />);
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
fireEvent.click(screen.getByTestId("mock-model-default"));
|
||||
rerender(<ChatThinkingLevelControl level="medium" onChange={vi.fn()} onChangeModel={onChangeModel} models={models} targetKey="session-a" modelProvider="openai" modelId="gpt-4o" defaultModelValue="openai/gpt-4o" />);
|
||||
expect(screen.getByTestId("chat-thinking-popover")).toBeDefined();
|
||||
});
|
||||
|
||||
it("closes for conversation identity changes before matching a pending target", () => {
|
||||
const onChangeModel = vi.fn();
|
||||
const { rerender } = render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} models={models} targetKey="session-a" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
fireEvent.click(screen.getByTestId("mock-model-dropdown"));
|
||||
rerender(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} models={models} targetKey="session-b" modelProvider="openai" modelId="gpt-4o" />);
|
||||
|
||||
expect(screen.queryByTestId("chat-thinking-popover")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes for unmatched target, rollback, level-only, and consumed echo changes", () => {
|
||||
const onChangeModel = vi.fn();
|
||||
const { rerender } = render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} models={models} targetKey="session-a" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
fireEvent.click(screen.getByTestId("mock-model-dropdown"));
|
||||
rerender(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} models={models} targetKey="session-a" modelProvider="anthropic" modelId="claude-sonnet-4-5" />);
|
||||
expect(screen.queryByTestId("chat-thinking-popover")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
rerender(<ChatThinkingLevelControl level="high" onChange={vi.fn()} onChangeModel={onChangeModel} models={models} targetKey="session-a" modelProvider="anthropic" modelId="claude-sonnet-4-5" />);
|
||||
expect(screen.queryByTestId("chat-thinking-popover")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
fireEvent.click(screen.getByTestId("mock-model-dropdown"));
|
||||
rerender(<ChatThinkingLevelControl level="high" onChange={vi.fn()} onChangeModel={onChangeModel} models={models} targetKey="session-a" modelProvider="openai" modelId="gpt-4o" />);
|
||||
expect(screen.getByTestId("chat-thinking-popover")).toBeDefined();
|
||||
rerender(<ChatThinkingLevelControl level="high" onChange={vi.fn()} onChangeModel={onChangeModel} models={models} targetKey="session-a" modelProvider="anthropic" modelId="claude-sonnet-4-5" />);
|
||||
expect(screen.queryByTestId("chat-thinking-popover")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves legacy omitted targetKey matching while no-host picker remains disabled", () => {
|
||||
const onChangeModel = vi.fn();
|
||||
const { rerender } = render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} models={models} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
fireEvent.click(screen.getByTestId("mock-model-dropdown"));
|
||||
rerender(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} models={models} modelProvider="openai" modelId="gpt-4o" />);
|
||||
expect(screen.getByTestId("chat-thinking-popover")).toBeDefined();
|
||||
|
||||
rerender(<ChatThinkingLevelControl level="high" onChange={vi.fn()} models={models} modelProvider="openai" modelId="gpt-4o" />);
|
||||
expect(screen.queryByTestId("chat-thinking-popover")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
expect(screen.getByTestId("mock-model-dropdown")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("reflects the active model and active agent selection", () => {
|
||||
|
||||
@@ -8,4 +8,8 @@ describe("ChatView brain control remains on direct composer", () => {
|
||||
expect(source).toContain("ChatThinkingLevelControl");
|
||||
expect(source).not.toContain("chatNewSessionMode");
|
||||
});
|
||||
|
||||
it("keys the direct composer target to its active session", () => {
|
||||
expect(source).toContain("targetKey={activeSession?.id ?? null}");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,6 +160,7 @@ describe("TaskDetailModal", () => {
|
||||
expect(modelBlock).toContain("inline-size: calc(var(--space-2xl) - var(--space-xs));");
|
||||
expect(modelBlock).not.toContain("text-overflow: ellipsis;");
|
||||
expect(css).toMatch(/\.task-planner-chat-empty\s*\{[^}]*margin:\s*0 auto auto;/);
|
||||
expect(composerBlock).toContain("position: relative;");
|
||||
expect(composerBlock).toContain("display: flex;");
|
||||
expect(composerBlock).toContain("flex-wrap: wrap;");
|
||||
expect(composerBlock).toContain("align-items: stretch;");
|
||||
@@ -169,13 +170,9 @@ describe("TaskDetailModal", () => {
|
||||
expect(inputBlock).toContain("min-height: calc(var(--space-2xl) + var(--space-sm));");
|
||||
expect(inputBlock).not.toContain("min-height: 5rem;");
|
||||
expect(mobileComposerBlock).toContain("flex-direction: row;");
|
||||
/*
|
||||
FNXC:TaskChatDefaultModel 2026-08-23-20:05:
|
||||
FN-033 gave the narrow composer a wrapping model/thinking control row (`.task-planner-chat-target-controls`
|
||||
takes `flex: 1 1 100%`), so the mobile composer wraps by design; the input and send button still
|
||||
share one row, which the sizing assertions below pin.
|
||||
*/
|
||||
expect(mobileComposerBlock).toContain("flex-wrap: wrap;");
|
||||
expect(css).not.toContain(".task-planner-chat-target-controls");
|
||||
expect(css).toMatch(/\.task-planner-chat-composer \.chat-thinking-popover\s*\{[^}]*left:\s*var\(--space-md\);[^}]*right:\s*var\(--space-md\);[^}]*width:\s*auto;/);
|
||||
expect(mobileComposerBlock).toContain("align-items: flex-end;");
|
||||
/*
|
||||
FNXC:ChatComposerHeight 2026-08-23-20:15:
|
||||
|
||||
@@ -270,8 +270,16 @@ describe("TaskPlannerChatTab", () => {
|
||||
});
|
||||
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
expect(screen.getByRole("button", { name: "Chat model" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Chat model" })).toBeNull();
|
||||
expect(screen.queryByTestId("task-planner-chat-target-controls")).toBeNull();
|
||||
expect(screen.getByTestId("chat-thinking-btn")).toHaveAccessibleName("Thinking level");
|
||||
await user.click(screen.getByTestId("chat-thinking-btn"));
|
||||
expect(screen.getByTestId("chat-thinking-popover")).toContainElement(screen.getByRole("button", { name: "Chat model" }));
|
||||
expect(screen.getByTestId("chat-thinking-model-picker")).toBeInTheDocument();
|
||||
expect(screen.getByRole("listbox")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("chat-thinking-mode-toggle")).toBeNull();
|
||||
expect(screen.queryByTestId("chat-thinking-agent-list")).toBeNull();
|
||||
await user.click(screen.getByTestId("chat-thinking-btn"));
|
||||
await user.click(screen.getByRole("button", { name: /Summarize recent activity/ }));
|
||||
|
||||
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith(
|
||||
@@ -289,6 +297,76 @@ describe("TaskPlannerChatTab", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the project default model through the unified popover and closes only after choosing a thinking level", async () => {
|
||||
const user = userEvent.setup();
|
||||
const projectDefault = { provider: "anthropic", modelId: "claude-plan", thinkingLevel: "high" };
|
||||
const existingSession = makePlannerSession({
|
||||
modelProvider: "enterprise-provider",
|
||||
modelId: "very-long-production-model",
|
||||
thinkingLevel: "off",
|
||||
});
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValue({ session: existingSession });
|
||||
mockFetchChatSession.mockResolvedValue({ session: existingSession });
|
||||
mockUpdateChatSession.mockResolvedValue({ session: makePlannerSession(projectDefault) });
|
||||
|
||||
renderPlannerChat({ taskChatModel: projectDefault });
|
||||
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
await user.click(screen.getByTestId("chat-thinking-btn"));
|
||||
await user.click(screen.getByRole("button", { name: "Chat model" }));
|
||||
const portal = await screen.findByTestId("model-combobox-portal");
|
||||
await user.click(within(portal).getByText("Use project default"));
|
||||
|
||||
await waitFor(() => expect(mockUpdateChatSession).toHaveBeenCalledWith(
|
||||
"chat-planner",
|
||||
{
|
||||
modelProvider: projectDefault.provider,
|
||||
modelId: projectDefault.modelId,
|
||||
thinkingLevel: projectDefault.thinkingLevel,
|
||||
},
|
||||
undefined,
|
||||
));
|
||||
expect(screen.getByTestId("chat-thinking-popover")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId("chat-thinking-option-high"));
|
||||
expect(screen.queryByTestId("chat-thinking-popover")).toBeNull();
|
||||
await waitFor(() => expect(mockUpdateChatSession).toHaveBeenCalledWith(
|
||||
"chat-planner",
|
||||
{ thinkingLevel: "high" },
|
||||
undefined,
|
||||
));
|
||||
});
|
||||
|
||||
it("keeps the popover open when first send acquires a session but closes it for another task", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
|
||||
mockEnsureTaskPlannerChatSession.mockResolvedValue(makePlannerSession());
|
||||
const { rerender } = renderPlannerChat();
|
||||
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
await user.type(screen.getByRole("textbox", { name: "Message task chat" }), "Create the first session");
|
||||
await user.click(screen.getByTestId("chat-thinking-btn"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
await waitFor(() => expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith(
|
||||
"FN-7310",
|
||||
{ modelProvider: "anthropic", modelId: "claude-plan" },
|
||||
undefined,
|
||||
));
|
||||
expect(screen.getByTestId("chat-thinking-popover")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TaskPlannerChatTab
|
||||
task={makeTask("FN-7312")}
|
||||
active
|
||||
taskChatModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId("chat-thinking-popover")).toBeNull());
|
||||
});
|
||||
|
||||
it("uses Direct Chat's readable portal for compact task-chat triggers and keeps long models searchable", async () => {
|
||||
const user = userEvent.setup();
|
||||
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
|
||||
@@ -300,6 +378,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
try {
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
await user.click(screen.getByTestId("chat-thinking-btn"));
|
||||
await user.click(screen.getByRole("button", { name: "Chat model" }));
|
||||
const portal = await screen.findByTestId("model-combobox-portal");
|
||||
|
||||
@@ -307,7 +386,11 @@ describe("TaskPlannerChatTab", () => {
|
||||
expect(Number.parseFloat(portal.style.width)).toBeGreaterThan(200);
|
||||
await user.type(within(portal).getByPlaceholderText("Filter models…"), "readable long");
|
||||
expect(within(portal).getByText("Enterprise Production Model With A Readable Long Name")).toBeInTheDocument();
|
||||
mockUpdateChatSession.mockResolvedValueOnce({
|
||||
session: makePlannerSession({ modelProvider: "enterprise-provider", modelId: "very-long-production-model" }),
|
||||
});
|
||||
await user.click(within(portal).getByText("Enterprise Production Model With A Readable Long Name"));
|
||||
expect(screen.getByTestId("chat-thinking-popover")).toBeInTheDocument();
|
||||
await waitFor(() => expect(mockUpdateChatSession).toHaveBeenCalledWith(
|
||||
"chat-planner",
|
||||
expect.objectContaining({ modelProvider: "enterprise-provider", modelId: "very-long-production-model" }),
|
||||
@@ -337,6 +420,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
try {
|
||||
renderPlannerChat({ taskChatModel: {} });
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
await user.click(screen.getByTestId("chat-thinking-btn"));
|
||||
await user.click(screen.getByRole("button", { name: "Chat model" }));
|
||||
const portal = await screen.findByTestId("model-combobox-portal");
|
||||
const left = Number.parseFloat(portal.style.left);
|
||||
|
||||
Reference in New Issue
Block a user