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:
gsxdsm
2026-07-04 10:27:57 -07:00
parent 7d8a1b8831
commit a2d6349bb8
4 changed files with 212 additions and 0 deletions

View 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.

View File

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

View File

@@ -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 = "";
};

View File

@@ -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%/);
});