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:
5
.changeset/fn-6518-quick-chat-focus.md
Normal file
5
.changeset/fn-6518-quick-chat-focus.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Bringing up Quick Chat now focuses the composer input on desktop (matching existing mobile behavior).
|
||||
@@ -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.
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(<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 () => {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
Reference in New Issue
Block a user