From 550715d10cbb30ef98e8f87b2305977ee8f1aca4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 17 Jun 2026 03:07:05 -0700 Subject: [PATCH] FN-6518: focus Quick Chat composer on open Quick Chat now places keyboard focus in the composer whenever the panel opens without stealing existing external focus. - Focus the Quick Chat composer after open on both desktop and mobile viewports. - Preserve the existing mobile stealth-input handoff while letting desktop use the ready-state focus path. - Add regression coverage for desktop, delayed session readiness, mobile handoff, and external focus preservation. - Document the updated Quick Chat focus behavior and add a patch changeset. Files changed: .changeset/fn-6518-quick-chat-focus.md | 5 + docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/QuickChatFAB.tsx | 6 +- .../app/components/__tests__/QuickChatFAB.test.tsx | 102 +++++++++++++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-6518 Fusion-Task-Lineage: 9fdaba17-e3f4-4269-91d9-aaad152c2ab1 --- .changeset/fn-6518-quick-chat-focus.md | 5 + docs/dashboard-guide.md | 2 +- .../dashboard/app/components/QuickChatFAB.tsx | 6 +- .../__tests__/QuickChatFAB.test.tsx | 102 ++++++++++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 .changeset/fn-6518-quick-chat-focus.md diff --git a/.changeset/fn-6518-quick-chat-focus.md b/.changeset/fn-6518-quick-chat-focus.md new file mode 100644 index 0000000000..778ab6126a --- /dev/null +++ b/.changeset/fn-6518-quick-chat-focus.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Bringing up Quick Chat now focuses the composer input on desktop (matching existing mobile behavior). diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 4404c7c2c0..7d0a42eb7a 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -302,7 +302,7 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv - Resume lookups still use targeted session queries instead of loading the full active-session list first - Tool-call summaries in the floating quick-chat panel are intentionally condensed into a single-line header row (especially on small screens) so tool name + status stay scannable without multi-line wrapping - Question tool calls use the same shared response card as full Chat, with compact spacing in the floating panel and read-only answered history so Quick Chat can continue agent clarification loops without exposing raw tool JSON. -- On mobile viewports, opening Quick Chat auto-focuses the composer as soon as it is ready so the keyboard opens immediately +- Opening Quick Chat auto-focuses the composer as soon as it is ready on desktop and mobile viewports; mobile additionally uses the stealth-input handoff so the soft keyboard opens immediately - FAB dragging uses pointer events with document-level move/up tracking and a 5px drag threshold so Android touch drags reposition reliably while short taps still open Quick Chat - Quick Chat now mirrors full Chat tail behavior: if you scroll up, live updates stop auto-following and a **Latest** jump control appears until you jump back down. - On mobile, Quick Chat re-anchors to the newest message whenever the panel is opened/reopened and when page visibility is restored, while still preserving the near-bottom gate so intentional scroll-away keeps **Latest** jump behavior. diff --git a/packages/dashboard/app/components/QuickChatFAB.tsx b/packages/dashboard/app/components/QuickChatFAB.tsx index e3eaa9d026..dd1c917bfa 100644 --- a/packages/dashboard/app/components/QuickChatFAB.tsx +++ b/packages/dashboard/app/components/QuickChatFAB.tsx @@ -1616,7 +1616,11 @@ export function QuickChatFAB({ return; } - shouldAutoFocusComposerRef.current = window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT; + /* + FNXC:QuickChat 2026-06-17-02:50: + Bringing up Quick Chat must focus the composer on every viewport so typing can start immediately. Mobile still claims the iOS keyboard through the stealth input first; the ready-state focus effect keeps that synchronous handoff while desktop reaches its requestAnimationFrame focus path. + */ + shouldAutoFocusComposerRef.current = true; }, [isOpen]); useEffect(() => { diff --git a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx index 8a85ba9cee..e13c1b24d2 100644 --- a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx @@ -161,6 +161,32 @@ function setQuickChatVisualViewportSample( }); } +function mockRequestAnimationFrames() { + const originalRaf = window.requestAnimationFrame; + const originalCancelRaf = window.cancelAnimationFrame; + const rafQueue: FrameRequestCallback[] = []; + window.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => { + rafQueue.push(cb); + return rafQueue.length; + }); + window.cancelAnimationFrame = vi.fn(); + + return { + async drain() { + await act(async () => { + while (rafQueue.length > 0) { + const cb = rafQueue.shift(); + cb?.(performance.now()); + } + }); + }, + restore() { + window.requestAnimationFrame = originalRaf; + window.cancelAnimationFrame = originalCancelRaf; + }, + }; +} + describe("QuickChatFAB session-first UX", () => { beforeEach(() => { vi.clearAllMocks(); @@ -1056,6 +1082,82 @@ describe("QuickChatFAB session-first UX", () => { expect(screen.getByTestId("quick-chat-session-option-session-model")).toBeInTheDocument(); }); + it("FN-6518: desktop opening Quick Chat focuses the enabled composer", async () => { + const raf = mockRequestAnimationFrames(); + + try { + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + + const input = await screen.findByTestId("quick-chat-input") as HTMLTextAreaElement; + await waitFor(() => expect(input).not.toBeDisabled()); + await raf.drain(); + + expect(document.activeElement).toBe(input); + } finally { + raf.restore(); + } + }); + + it("FN-6518: desktop composer focuses after the session becomes ready post-open", async () => { + const raf = mockRequestAnimationFrames(); + const deferredSessions = createDeferredPromise<{ sessions: ChatSession[] }>(); + mockFetchChatSessions.mockImplementationOnce(() => deferredSessions.promise); + + try { + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + + const input = await screen.findByTestId("quick-chat-input") as HTMLTextAreaElement; + expect(input).toBeDisabled(); + deferredSessions.resolve({ sessions: [modelSession, agentSession] }); + await waitFor(() => expect(input).not.toBeDisabled()); + await raf.drain(); + + expect(document.activeElement).toBe(input); + } finally { + raf.restore(); + } + }); + + it("FN-6518: mobile opening Quick Chat hands focus from stealth input to composer", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + window.dispatchEvent(new Event("resize")); + mockUseViewportMode.mockReturnValue("mobile"); + + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + + const input = await screen.findByTestId("quick-chat-input") as HTMLTextAreaElement; + await waitFor(() => expect(input).not.toBeDisabled()); + + expect(document.activeElement).toBe(input); + }); + + it("FN-6518: auto-focus does not steal focus from an external control", async () => { + const raf = mockRequestAnimationFrames(); + const externalFocusTarget = document.createElement("button"); + externalFocusTarget.type = "button"; + externalFocusTarget.textContent = "External focus target"; + document.body.appendChild(externalFocusTarget); + + try { + const { rerender } = render(); + externalFocusTarget.focus(); + expect(document.activeElement).toBe(externalFocusTarget); + + rerender(); + const input = await screen.findByTestId("quick-chat-input") as HTMLTextAreaElement; + await waitFor(() => expect(input).not.toBeDisabled()); + await raf.drain(); + + expect(document.activeElement).toBe(externalFocusTarget); + } finally { + externalFocusTarget.remove(); + raf.restore(); + } + }); + it("FN-6301: iOS first tap focuses composer without canceling native focus, then sends", async () => { Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); window.dispatchEvent(new Event("resize"));