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
This commit is contained in:
gsxdsm
2026-06-17 03:07:05 -07:00
parent d17a6dbd74
commit 550715d10c
4 changed files with 113 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Bringing up Quick Chat now focuses the composer input on desktop (matching existing mobile behavior).

View File

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

View File

@@ -1616,7 +1616,11 @@ export function QuickChatFAB({
return; 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]); }, [isOpen]);
useEffect(() => { useEffect(() => {

View File

@@ -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", () => { describe("QuickChatFAB session-first UX", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -1056,6 +1082,82 @@ describe("QuickChatFAB session-first UX", () => {
expect(screen.getByTestId("quick-chat-session-option-session-model")).toBeInTheDocument(); 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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" open={false} onOpenChange={vi.fn()} />);
externalFocusTarget.focus();
expect(document.activeElement).toBe(externalFocusTarget);
rerender(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" open onOpenChange={vi.fn()} />);
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 () => { it("FN-6301: iOS first tap focuses composer without canceling native focus, then sends", async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
window.dispatchEvent(new Event("resize")); window.dispatchEvent(new Event("resize"));