FN-7504: keep mobile chat composer above keyboard chrome
Keeps the mobile Chat composer visible when iOS keyboard accessory chrome is open. - Add ChatView-local keyboard accessory clearance for iOS keyboard-active composer padding. - Reset the clearance when keyboard tracking is suppressed or torn down, without adding persistent thread transforms. - Cover empty, streaming, room, and Android mobile composer behavior with regression tests. - Add a patch changeset for the published Fusion package. Files changed: .../fn-7504-mobile-chat-composer-keyboard.md | 7 + packages/dashboard/app/components/ChatView.css | 4 + packages/dashboard/app/components/ChatView.tsx | 10 ++ .../components/__tests__/ChatView.mobile.test.tsx | 191 +++++++++++++++++++++ 4 files changed, 212 insertions(+) Fusion-Task-Id: FN-7504 Fusion-Task-Lineage: 4e7ed36e-e7d5-4cd1-934b-3b0f19ed6615 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7504-mobile-chat-composer-keyboard.md
Normal file
7
.changeset/fn-7504-mobile-chat-composer-keyboard.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix mobile Chat composer being hidden behind the keyboard accessory bar.
|
||||
category: fix
|
||||
dev: Adds keyboard-open bottom clearance in ChatView so the composer clears the iOS input-assistant/autofill bar without a persistent .chat-thread transform or Android reserved-gap.
|
||||
@@ -2175,6 +2175,10 @@ Queued-message banners stack above the composer input with a capped scroll area,
|
||||
ancestor stays `transform: none` and the keyboard stays up. */
|
||||
}
|
||||
|
||||
.chat-thread--keyboard-active .chat-input-area {
|
||||
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px) + var(--chat-keyboard-accessory-clearance, 0px));
|
||||
}
|
||||
|
||||
/* On mobile, the active scope affordance uses a full-width pinned footer. */
|
||||
.chat-sidebar-footer {
|
||||
display: block;
|
||||
|
||||
@@ -1145,6 +1145,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
const apply = () => {
|
||||
if (suppressVvShrinkRef.current) {
|
||||
thread.classList.remove("chat-thread--keyboard-active");
|
||||
thread.style.setProperty("--chat-keyboard-accessory-clearance", "0px");
|
||||
thread.style.transform = "";
|
||||
thread.style.willChange = "";
|
||||
return;
|
||||
@@ -1157,6 +1158,14 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
|
||||
const keyboardActive = (overlap > 0 || offsetTop > 0) && isKeyboardTrackingFocusable(document.activeElement);
|
||||
thread.classList.toggle("chat-thread--keyboard-active", keyboardActive);
|
||||
/*
|
||||
FNXC:ChatComposer 2026-07-04-09:42:
|
||||
Mobile Chat's composer must stay fully visible above the soft keyboard and the iOS input-assistant/autofill bar, which Safari does not subtract from visualViewport.height. Keep the clearance ChatView-local and keyed to iOS keyboard-active state so the shared keyboard hook contract stays stable, .chat-thread does not gain a persistent transform (anti-blur invariant), and Android resizes-content does not regain an empty reserved gap.
|
||||
*/
|
||||
thread.style.setProperty(
|
||||
"--chat-keyboard-accessory-clearance",
|
||||
keyboardActive && isIOS() ? "calc(var(--space-2xl) + var(--space-md))" : "0px",
|
||||
);
|
||||
|
||||
// Drift compensation is applied here (not in CSS) so .chat-thread —
|
||||
// an ancestor of the focused composer textarea — only gets a
|
||||
@@ -1190,6 +1199,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
window.removeEventListener("pageshow", apply);
|
||||
document.removeEventListener("visibilitychange", apply);
|
||||
thread.classList.remove("chat-thread--keyboard-active");
|
||||
thread.style.setProperty("--chat-keyboard-accessory-clearance", "0px");
|
||||
thread.style.transform = "";
|
||||
thread.style.willChange = "";
|
||||
};
|
||||
|
||||
@@ -208,6 +208,34 @@ describe("ChatView mobile behavior", () => {
|
||||
}));
|
||||
}
|
||||
|
||||
async function focusComposerAndOpenKeyboard({
|
||||
listeners,
|
||||
mockVV,
|
||||
vvHeight,
|
||||
offsetTop = 0,
|
||||
}: {
|
||||
listeners: Record<string, Array<() => void>>;
|
||||
mockVV: VisualViewport;
|
||||
vvHeight: number;
|
||||
offsetTop?: number;
|
||||
}) {
|
||||
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
textarea.focus();
|
||||
});
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("focusin"));
|
||||
});
|
||||
|
||||
Object.defineProperty(mockVV, "offsetTop", { value: offsetTop, writable: true, configurable: true });
|
||||
Object.defineProperty(mockVV, "height", { value: vvHeight, writable: true, configurable: true });
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
return textarea;
|
||||
}
|
||||
|
||||
it("mobile mode: does not render thread header when no active session (list view)", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
try {
|
||||
@@ -842,6 +870,164 @@ describe("ChatView mobile behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: keeps direct empty composer above iOS keyboard accessory chrome without a transform", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true);
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
try {
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
|
||||
const inputArea = document.querySelector(".chat-input-area") as HTMLDivElement;
|
||||
const inputRow = document.querySelector(".chat-input-row") as HTMLDivElement;
|
||||
expect(thread).toBeInTheDocument();
|
||||
expect(inputArea).toBeInTheDocument();
|
||||
expect(inputRow).toBeInTheDocument();
|
||||
expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("0px");
|
||||
|
||||
const textarea = await focusComposerAndOpenKeyboard({ listeners, mockVV, vvHeight: 560 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true);
|
||||
expect(thread.style.getPropertyValue("--vv-height")).toBe("560px");
|
||||
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("284px");
|
||||
expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("calc(var(--space-2xl) + var(--space-md))");
|
||||
expect(thread.style.transform).toBe("");
|
||||
expect(thread.style.willChange).toBe("");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
textarea.blur();
|
||||
document.dispatchEvent(new Event("focusout"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(false);
|
||||
expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("0px");
|
||||
});
|
||||
} finally {
|
||||
isIOSSpy.mockRestore();
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: keeps populated streaming direct composer above iOS accessory chrome", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true);
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
try {
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [
|
||||
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Loaded history", createdAt: "2026-04-08T00:00:00.000Z" },
|
||||
],
|
||||
isStreaming: true,
|
||||
streamingText: "Streaming response",
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
|
||||
await focusComposerAndOpenKeyboard({ listeners, mockVV, vvHeight: 560 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Loaded history")).toBeInTheDocument();
|
||||
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true);
|
||||
expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("calc(var(--space-2xl) + var(--space-md))");
|
||||
});
|
||||
} finally {
|
||||
isIOSSpy.mockRestore();
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: keeps room composer above iOS keyboard accessory chrome", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true);
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
localStorage.setItem("fusion:chat-scope", "rooms");
|
||||
try {
|
||||
const room = createRoomFixture("general");
|
||||
setupMockChat({ activeSession: null, messages: [] });
|
||||
setupMockRooms({
|
||||
activeRoom: room,
|
||||
rooms: [room],
|
||||
messages: [
|
||||
{
|
||||
id: "room-msg-001",
|
||||
roomId: room.id,
|
||||
role: "assistant",
|
||||
content: "Room history",
|
||||
thinkingOutput: null,
|
||||
toolCalls: [],
|
||||
createdAt: "2026-05-12T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
|
||||
await focusComposerAndOpenKeyboard({ listeners, mockVV, vvHeight: 560 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Room history")).toBeInTheDocument();
|
||||
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true);
|
||||
expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("calc(var(--space-2xl) + var(--space-md))");
|
||||
});
|
||||
} finally {
|
||||
localStorage.removeItem("fusion:chat-scope");
|
||||
isIOSSpy.mockRestore();
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: does not reserve iOS accessory clearance on Android resizes-content keyboard samples", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(false);
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
try {
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
|
||||
await focusComposerAndOpenKeyboard({ listeners, mockVV, vvHeight: 560 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true);
|
||||
expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("0px");
|
||||
});
|
||||
} finally {
|
||||
isIOSSpy.mockRestore();
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: applies keyboard-active class for iOS fallback when viewport offset is present", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
@@ -2011,6 +2197,11 @@ describe("ChatView mobile CSS contract", () => {
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread--keyboard-active\s*\{[^}]*--vv-height/);
|
||||
});
|
||||
|
||||
it("mobile adds tokenized keyboard accessory clearance only to active composer padding", async () => {
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread--keyboard-active\s+\.chat-input-area\s*\{[^}]*padding-bottom:\s*calc\(var\(--space-md\) \+ env\(safe-area-inset-bottom, 0px\) \+ var\(--chat-keyboard-accessory-clearance, 0px\)\)/);
|
||||
expect(css).toMatch(/\.chat-input-area\s*\{[^}]*padding:\s*var\(--space-md\) var\(--space-lg\)[^}]*\}/);
|
||||
});
|
||||
|
||||
it("mobile makes chat bubbles full-width for narrow-column readability", async () => {
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message\s*\{[^}]*max-width:\s*100%/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user