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 });
|
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>) => {
|
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -197,7 +209,13 @@ export function ChatThinkingLevelControl({
|
|||||||
type="button"
|
type="button"
|
||||||
className={`chat-thinking-mode-btn${targetMode === "model" ? " chat-thinking-mode-btn--active" : ""}`}
|
className={`chat-thinking-mode-btn${targetMode === "model" ? " chat-thinking-mode-btn--active" : ""}`}
|
||||||
data-testid="chat-thinking-mode-model"
|
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")}
|
{t("chat.newChatModeModel", "Model")}
|
||||||
</button>
|
</button>
|
||||||
@@ -205,7 +223,13 @@ export function ChatThinkingLevelControl({
|
|||||||
type="button"
|
type="button"
|
||||||
className={`chat-thinking-mode-btn${targetMode === "agent" ? " chat-thinking-mode-btn--active" : ""}`}
|
className={`chat-thinking-mode-btn${targetMode === "agent" ? " chat-thinking-mode-btn--active" : ""}`}
|
||||||
data-testid="chat-thinking-mode-agent"
|
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")}
|
{t("chat.newChatModeAgent", "Agent")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -126,15 +126,36 @@ describe("ChatThinkingLevelControl", () => {
|
|||||||
expect(screen.queryByRole("listbox")).toBeNull();
|
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} />);
|
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} models={models} agents={agents} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
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(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-list")).toBeDefined();
|
||||||
expect(screen.getByTestId("chat-thinking-agent-agent-001")).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", () => {
|
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()} />);
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
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"));
|
fireEvent.click(screen.getByTestId("chat-thinking-agent-agent-002"));
|
||||||
|
|
||||||
expect(setSessionModel).toHaveBeenCalledWith("sess-agent", { agentId: "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");
|
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 () => {
|
it.each([
|
||||||
mockMobileViewport();
|
{ name: "mobile", configure: mockMobileViewport, props: {} },
|
||||||
const session = makeSession({ id: "sess-mobile", cliExecutorAdapterId: null, thinkingLevel: null });
|
{ name: "narrow floating", configure: mockDesktopViewport, props: { floating: true, compactLayout: true } },
|
||||||
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session] }));
|
])("(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");
|
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-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 () => {
|
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