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. */
|
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. */
|
/* On mobile, the active scope affordance uses a full-width pinned footer. */
|
||||||
.chat-sidebar-footer {
|
.chat-sidebar-footer {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -1145,6 +1145,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
const apply = () => {
|
const apply = () => {
|
||||||
if (suppressVvShrinkRef.current) {
|
if (suppressVvShrinkRef.current) {
|
||||||
thread.classList.remove("chat-thread--keyboard-active");
|
thread.classList.remove("chat-thread--keyboard-active");
|
||||||
|
thread.style.setProperty("--chat-keyboard-accessory-clearance", "0px");
|
||||||
thread.style.transform = "";
|
thread.style.transform = "";
|
||||||
thread.style.willChange = "";
|
thread.style.willChange = "";
|
||||||
return;
|
return;
|
||||||
@@ -1157,6 +1158,14 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
|
|
||||||
const keyboardActive = (overlap > 0 || offsetTop > 0) && isKeyboardTrackingFocusable(document.activeElement);
|
const keyboardActive = (overlap > 0 || offsetTop > 0) && isKeyboardTrackingFocusable(document.activeElement);
|
||||||
thread.classList.toggle("chat-thread--keyboard-active", keyboardActive);
|
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 —
|
// Drift compensation is applied here (not in CSS) so .chat-thread —
|
||||||
// an ancestor of the focused composer textarea — only gets a
|
// 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);
|
window.removeEventListener("pageshow", apply);
|
||||||
document.removeEventListener("visibilitychange", apply);
|
document.removeEventListener("visibilitychange", apply);
|
||||||
thread.classList.remove("chat-thread--keyboard-active");
|
thread.classList.remove("chat-thread--keyboard-active");
|
||||||
|
thread.style.setProperty("--chat-keyboard-accessory-clearance", "0px");
|
||||||
thread.style.transform = "";
|
thread.style.transform = "";
|
||||||
thread.style.willChange = "";
|
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 () => {
|
it("mobile mode: does not render thread header when no active session (list view)", async () => {
|
||||||
const restoreMatchMedia = mockMobileViewport();
|
const restoreMatchMedia = mockMobileViewport();
|
||||||
try {
|
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 () => {
|
it("mobile mode: applies keyboard-active class for iOS fallback when viewport offset is present", async () => {
|
||||||
const restoreMatchMedia = mockMobileViewport();
|
const restoreMatchMedia = mockMobileViewport();
|
||||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
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/);
|
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 () => {
|
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%/);
|
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