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
|
- 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.
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
Reference in New Issue
Block a user