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:
Fusion
2026-04-30 00:27:50 -07:00
committed by gsxdsm
parent dc96d572cb
commit b359cb8672
3 changed files with 91 additions and 7 deletions

View File

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

View File

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