FN-8556: fix Windows agent target selection
Make direct-chat Agent selection reliable when Windows Electron suppresses the click after pointer activation. - Switch target mode on primary pointerdown while retaining keyboard activation. - Expose selected Model and Agent modes with aria-pressed semantics. - Cover pointer activation and persistence across direct-chat layouts. - Add a patch changeset for the Windows selection fix. Files changed: .changeset/fn-8556-windows-agent-selection.md | 7 ++++ .../app/components/ChatThinkingLevelControl.tsx | 28 ++++++++++++- .../__tests__/ChatThinkingLevelControl.test.tsx | 27 +++++++++++-- .../__tests__/ChatView.thinking-level.test.tsx | 46 +++++++++++++++++----- 4 files changed, 93 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-8556 Fusion-Task-Lineage: 25b06177-3b59-4d2d-8440-e92fe7172259 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8556-windows-agent-selection.md
Normal file
7
.changeset/fn-8556-windows-agent-selection.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Make Windows direct-chat Agent selection switch visibly and reliably.
|
||||
category: fix
|
||||
dev: Handles primary pointer activation before an Electron host can suppress its following click.
|
||||
@@ -139,6 +139,18 @@ export function ChatThinkingLevelControl({
|
||||
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);
|
||||
@@ -197,7 +209,13 @@ export function ChatThinkingLevelControl({
|
||||
type="button"
|
||||
className={`chat-thinking-mode-btn${targetMode === "model" ? " chat-thinking-mode-btn--active" : ""}`}
|
||||
data-testid="chat-thinking-mode-model"
|
||||
onClick={() => setTargetMode("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>
|
||||
@@ -205,7 +223,13 @@ export function ChatThinkingLevelControl({
|
||||
type="button"
|
||||
className={`chat-thinking-mode-btn${targetMode === "agent" ? " chat-thinking-mode-btn--active" : ""}`}
|
||||
data-testid="chat-thinking-mode-agent"
|
||||
onClick={() => setTargetMode("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>
|
||||
|
||||
@@ -126,15 +126,36 @@ describe("ChatThinkingLevelControl", () => {
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("the Model|Agent toggle swaps controls", () => {
|
||||
it("switches to Agent mode during the Windows pointer activation sequence and exposes selected semantics", () => {
|
||||
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} models={models} agents={agents} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
const modelMode = screen.getByTestId("chat-thinking-mode-model");
|
||||
const agentMode = screen.getByTestId("chat-thinking-mode-agent");
|
||||
expect(screen.getByTestId("mock-model-dropdown")).toBeDefined();
|
||||
expect(modelMode).toHaveAttribute("aria-pressed", "true");
|
||||
expect(agentMode).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-mode-agent"));
|
||||
// Electron on Windows visibly dispatches pointerdown before it completes click activation.
|
||||
// Agent mode must render from that primary-pointer boundary, not wait for a click that a
|
||||
// host surface can suppress after showing the pressed state.
|
||||
fireEvent.pointerDown(agentMode, { button: 0, pointerType: "mouse" });
|
||||
expect(agentMode).toHaveClass("chat-thinking-mode-btn--active");
|
||||
expect(agentMode).toHaveAttribute("aria-pressed", "true");
|
||||
expect(modelMode).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.queryByTestId("chat-thinking-model-picker")).toBeNull();
|
||||
expect(screen.getByTestId("chat-thinking-agent-list")).toBeDefined();
|
||||
expect(screen.getByTestId("chat-thinking-agent-agent-001")).toBeDefined();
|
||||
|
||||
fireEvent.pointerUp(agentMode, { button: 0, pointerType: "mouse" });
|
||||
fireEvent.click(agentMode, { detail: 1 });
|
||||
expect(screen.getByTestId("chat-thinking-agent-list")).toBeDefined();
|
||||
|
||||
// Keyboard button activation remains a click with no pointer detail.
|
||||
fireEvent.click(modelMode, { detail: 0 });
|
||||
expect(modelMode).toHaveClass("chat-thinking-mode-btn--active");
|
||||
expect(modelMode).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByTestId("chat-thinking-model-picker")).toBeDefined();
|
||||
});
|
||||
|
||||
it("selecting a model calls onChangeModel with the provider/model pair and closes", () => {
|
||||
|
||||
@@ -242,7 +242,12 @@ describe("ChatView thinking-level control (FN-7898)", () => {
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-mode-agent"));
|
||||
const agentMode = screen.getByTestId("chat-thinking-mode-agent");
|
||||
fireEvent.pointerDown(agentMode, { button: 0, pointerType: "mouse" });
|
||||
fireEvent.pointerUp(agentMode, { button: 0, pointerType: "mouse" });
|
||||
fireEvent.click(agentMode, { detail: 1 });
|
||||
expect(agentMode).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.queryByTestId("chat-thinking-model-picker")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-agent-agent-002"));
|
||||
|
||||
expect(setSessionModel).toHaveBeenCalledWith("sess-agent", { agentId: "agent-002" });
|
||||
@@ -313,19 +318,40 @@ describe("ChatView thinking-level control (FN-7898)", () => {
|
||||
expect(screen.getByTestId("chat-thinking-agent-agent-001").className).toContain("chat-thinking-agent-item--selected");
|
||||
});
|
||||
|
||||
it("(f) renders the trigger without a layout/overflow regression at a mobile viewport", async () => {
|
||||
mockMobileViewport();
|
||||
const session = makeSession({ id: "sess-mobile", cliExecutorAdapterId: null, thinkingLevel: null });
|
||||
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session] }));
|
||||
it.each([
|
||||
{ name: "mobile", configure: mockMobileViewport, props: {} },
|
||||
{ name: "narrow floating", configure: mockDesktopViewport, props: { floating: true, compactLayout: true } },
|
||||
])("(f) switches to and persists an Agent target from the $name direct-chat host", async ({ name, configure, props }) => {
|
||||
configure();
|
||||
const setSessionModel = vi.fn();
|
||||
const session = makeSession({ id: `sess-${name.replaceAll(" ", "-")}`, cliExecutorAdapterId: null, agentId: useChatModule.FN_AGENT_ID });
|
||||
const agentsMap = new Map([
|
||||
["agent-001", { id: "agent-001", name: "Alpha", role: "executor" }],
|
||||
["agent-002", { id: "agent-002", name: "Beta", role: "reviewer" }],
|
||||
] as const);
|
||||
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session], agentsMap, setSessionModel }));
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} {...props} />);
|
||||
|
||||
expect(document.querySelector(".chat-view--narrow")).toBeTruthy();
|
||||
if (props.floating) expect(document.querySelector(".chat-view--floating")).toBeTruthy();
|
||||
|
||||
const trigger = screen.getByTestId("chat-thinking-btn");
|
||||
expect(trigger).toBeInTheDocument();
|
||||
// Shell/structure assertion consistent with other ChatView mobile tests:
|
||||
// the trigger sits alongside the attach button inside the same input row.
|
||||
expect(trigger.closest(".chat-input-row")).not.toBeNull();
|
||||
expect(trigger.closest(".chat-thinking-level-root")).not.toBeNull();
|
||||
fireEvent.click(trigger);
|
||||
|
||||
// Match the Windows Electron pointer activation whose click can be consumed by a host.
|
||||
const agentMode = screen.getByTestId("chat-thinking-mode-agent");
|
||||
fireEvent.pointerDown(agentMode, { button: 0, pointerType: "mouse" });
|
||||
fireEvent.pointerUp(agentMode, { button: 0, pointerType: "mouse" });
|
||||
fireEvent.click(agentMode, { detail: 1 });
|
||||
|
||||
expect(agentMode).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.queryByTestId("chat-thinking-model-picker")).toBeNull();
|
||||
expect(screen.getByTestId("chat-thinking-agent-list")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("chat-thinking-agent-agent-002"));
|
||||
|
||||
expect(setSessionModel).toHaveBeenCalledWith(session.id, { agentId: "agent-002" });
|
||||
});
|
||||
|
||||
it("(g) uses the resolved Settings default for both the in-chat and New Chat thinking-level labels", async () => {
|
||||
|
||||
Reference in New Issue
Block a user