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:
gsxdsm
2026-07-24 00:35:34 -07:00
parent f6795317a8
commit c3cebcd56b
4 changed files with 92 additions and 14 deletions

View 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.

View File

@@ -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>

View File

@@ -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", () => {

View File

@@ -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 () => {