feat(FN-3022): merge fusion/fn-3022
Commits merged: - feat(FN-3022): complete Step 4 — document mobile quick chat autofocus behavior - test(FN-3022): complete Step 2 — cover mobile autofocus and delayed enable path - feat(FN-3022): complete Step 1 — reliable mobile autofocus when composer becomes ready Files changed: docs/dashboard-guide.md | 1 + packages/dashboard/app/components/QuickChatFAB.tsx | 42 +++++++++++++++--- .../app/components/__tests__/QuickChatFAB.test.tsx | 51 ++++++++++++++++++++++ 3 files changed, 89 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-3022
This commit is contained in:
@@ -806,6 +806,7 @@ export function QuickChatFAB({
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
|
||||
const shouldAutoFocusComposerRef = useRef(false);
|
||||
|
||||
const resolvedModelSelection = selectedModel || configuredDefaultModelSelection;
|
||||
|
||||
@@ -834,6 +835,7 @@ export function QuickChatFAB({
|
||||
}, [chatMode, parsedModelSelection, selectedAgentId]);
|
||||
|
||||
const hasChatTarget = chatMode === "agent" ? Boolean(selectedAgentId) : Boolean(parsedModelSelection);
|
||||
const inputDisabled = !hasChatTarget || !activeSession;
|
||||
|
||||
useEffect(() => {
|
||||
if (agents.length === 0) {
|
||||
@@ -968,13 +970,45 @@ export function QuickChatFAB({
|
||||
}, [pendingAttachments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
if (!isOpen) {
|
||||
shouldAutoFocusComposerRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldAutoFocusComposerRef.current = window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT;
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || inputDisabled || !shouldAutoFocusComposerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = inputRef.current;
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
const panelContainsFocus = activeElement ? panelRef.current?.contains(activeElement) : false;
|
||||
const isBodyFocused = activeElement === document.body;
|
||||
|
||||
if (!panelContainsFocus && !isBodyFocused) {
|
||||
shouldAutoFocusComposerRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
input.focus();
|
||||
shouldAutoFocusComposerRef.current = false;
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [isOpen, inputDisabled]);
|
||||
|
||||
// Attachment object URLs must be revoked when the composer unmounts.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1086,8 +1120,6 @@ export function QuickChatFAB({
|
||||
return "Select a model to start chatting";
|
||||
}, [chatMode, selectedAgent, selectedModelTag]);
|
||||
|
||||
const inputDisabled = !hasChatTarget || !activeSession;
|
||||
|
||||
const pendingPreview = pendingMessage.length > 50
|
||||
? `${pendingMessage.slice(0, 50)}…`
|
||||
: pendingMessage;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ChatSession } from "@fusion/core";
|
||||
import * as apiModule from "../../api";
|
||||
import { useAgents } from "../../hooks/useAgents";
|
||||
import { QuickChatFAB } from "../QuickChatFAB";
|
||||
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchResumeChatSession: vi.fn(),
|
||||
@@ -1929,6 +1930,7 @@ describe("QuickChatFAB", () => {
|
||||
let savedOntouchstart: typeof window.ontouchstart;
|
||||
|
||||
beforeEach(() => {
|
||||
_resetInitialViewportHeight();
|
||||
savedVisualViewport = window.visualViewport;
|
||||
savedInnerWidth = window.innerWidth;
|
||||
savedInnerHeight = window.innerHeight;
|
||||
@@ -2002,6 +2004,55 @@ describe("QuickChatFAB", () => {
|
||||
return { listeners, mockVV };
|
||||
}
|
||||
|
||||
it("auto-focuses the composer when quick chat opens on mobile with a ready session", async () => {
|
||||
mockMobileVisualViewport({
|
||||
innerHeight: 800,
|
||||
vvHeight: 800,
|
||||
});
|
||||
|
||||
render(<QuickChatFAB addToast={addToast} open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const input = await screen.findByTestId("quick-chat-input");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input).not.toBeDisabled();
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-focuses when mobile composer becomes enabled after async session initialization", async () => {
|
||||
const createdSession: ChatSession = {
|
||||
...mockSession,
|
||||
id: "session-mobile-delayed",
|
||||
};
|
||||
|
||||
let resolveSessionCreation: ((value: { session: ChatSession }) => void) | null = null;
|
||||
mockCreateChatSession.mockImplementationOnce(
|
||||
() => new Promise((resolve) => {
|
||||
resolveSessionCreation = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
mockMobileVisualViewport({
|
||||
innerHeight: 800,
|
||||
vvHeight: 800,
|
||||
});
|
||||
|
||||
render(<QuickChatFAB addToast={addToast} open={true} onOpenChange={vi.fn()} projectId="proj-123" />);
|
||||
|
||||
const input = await screen.findByTestId("quick-chat-input");
|
||||
expect(input).toBeDisabled();
|
||||
|
||||
await act(async () => {
|
||||
resolveSessionCreation?.({ session: createdSession });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input).not.toBeDisabled();
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
it("sets keyboard overlap CSS variable when mobile viewport shrinks", async () => {
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
|
||||
Reference in New Issue
Block a user