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:
@@ -49,6 +49,7 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv
|
|||||||
- `startFreshSession()` is the explicit new-thread action and always creates a new persisted session
|
- `startFreshSession()` is the explicit new-thread action and always creates a new persisted session
|
||||||
- Resume lookups use a targeted session query instead of loading the full active session list first
|
- Resume lookups use a targeted session query 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
|
||||||
|
- On mobile viewports, opening Quick Chat auto-focuses the composer as soon as it is ready so the keyboard opens immediately
|
||||||
|
|
||||||
## Mailbox View
|
## Mailbox View
|
||||||
|
|
||||||
|
|||||||
@@ -806,6 +806,7 @@ export function QuickChatFAB({
|
|||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
|
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
|
||||||
|
const shouldAutoFocusComposerRef = useRef(false);
|
||||||
|
|
||||||
const resolvedModelSelection = selectedModel || configuredDefaultModelSelection;
|
const resolvedModelSelection = selectedModel || configuredDefaultModelSelection;
|
||||||
|
|
||||||
@@ -834,6 +835,7 @@ export function QuickChatFAB({
|
|||||||
}, [chatMode, parsedModelSelection, selectedAgentId]);
|
}, [chatMode, parsedModelSelection, selectedAgentId]);
|
||||||
|
|
||||||
const hasChatTarget = chatMode === "agent" ? Boolean(selectedAgentId) : Boolean(parsedModelSelection);
|
const hasChatTarget = chatMode === "agent" ? Boolean(selectedAgentId) : Boolean(parsedModelSelection);
|
||||||
|
const inputDisabled = !hasChatTarget || !activeSession;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (agents.length === 0) {
|
if (agents.length === 0) {
|
||||||
@@ -968,13 +970,45 @@ export function QuickChatFAB({
|
|||||||
}, [pendingAttachments]);
|
}, [pendingAttachments]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) {
|
||||||
const frame = requestAnimationFrame(() => {
|
shouldAutoFocusComposerRef.current = false;
|
||||||
inputRef.current?.focus();
|
return;
|
||||||
});
|
}
|
||||||
return () => cancelAnimationFrame(frame);
|
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldAutoFocusComposerRef.current = window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT;
|
||||||
}, [isOpen]);
|
}, [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.
|
// Attachment object URLs must be revoked when the composer unmounts.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1086,8 +1120,6 @@ export function QuickChatFAB({
|
|||||||
return "Select a model to start chatting";
|
return "Select a model to start chatting";
|
||||||
}, [chatMode, selectedAgent, selectedModelTag]);
|
}, [chatMode, selectedAgent, selectedModelTag]);
|
||||||
|
|
||||||
const inputDisabled = !hasChatTarget || !activeSession;
|
|
||||||
|
|
||||||
const pendingPreview = pendingMessage.length > 50
|
const pendingPreview = pendingMessage.length > 50
|
||||||
? `${pendingMessage.slice(0, 50)}…`
|
? `${pendingMessage.slice(0, 50)}…`
|
||||||
: pendingMessage;
|
: pendingMessage;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { ChatSession } from "@fusion/core";
|
|||||||
import * as apiModule from "../../api";
|
import * as apiModule from "../../api";
|
||||||
import { useAgents } from "../../hooks/useAgents";
|
import { useAgents } from "../../hooks/useAgents";
|
||||||
import { QuickChatFAB } from "../QuickChatFAB";
|
import { QuickChatFAB } from "../QuickChatFAB";
|
||||||
|
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchResumeChatSession: vi.fn(),
|
fetchResumeChatSession: vi.fn(),
|
||||||
@@ -1929,6 +1930,7 @@ describe("QuickChatFAB", () => {
|
|||||||
let savedOntouchstart: typeof window.ontouchstart;
|
let savedOntouchstart: typeof window.ontouchstart;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
_resetInitialViewportHeight();
|
||||||
savedVisualViewport = window.visualViewport;
|
savedVisualViewport = window.visualViewport;
|
||||||
savedInnerWidth = window.innerWidth;
|
savedInnerWidth = window.innerWidth;
|
||||||
savedInnerHeight = window.innerHeight;
|
savedInnerHeight = window.innerHeight;
|
||||||
@@ -2002,6 +2004,55 @@ describe("QuickChatFAB", () => {
|
|||||||
return { listeners, mockVV };
|
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 () => {
|
it("sets keyboard overlap CSS variable when mobile viewport shrinks", async () => {
|
||||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||||
innerHeight: 844,
|
innerHeight: 844,
|
||||||
|
|||||||
Reference in New Issue
Block a user