FN-8191: clamp conversation action menus to viewport
Keep conversation overflow menus aligned with their triggers and contained in the visible viewport. - Measure rendered menu dimensions so theme-specific widths remain aligned - Clamp horizontal and vertical menu positions for trigger and context-menu entry points - Cover desktop alignment and mobile alternate-theme viewport containment Files changed: packages/dashboard/app/components/ChatView.css | 6 ++ packages/dashboard/app/components/ChatView.tsx | 72 +++++++++++++++++++++- .../__tests__/ChatView.core-interactions.test.tsx | 32 ++++++++++ .../components/__tests__/ChatView.mobile.test.tsx | 41 +++++++++++- 4 files changed, 147 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-8191 Fusion-Task-Lineage: 1a52140b-9450-421d-844b-9fff302d8ea4 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -436,9 +436,15 @@ FN-8173 replaces the inline Pin/Rename/Delete cluster with one overflow trigger.
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
FNXC:ChatSidebar 2026-07-16-23:55:
|
||||
FN-8191 gives the conversation action menu a token-composed border-box width. ChatView measures that rendered width before clamping, so alternate themes keep the mobile menu's right edge aligned without raw CSS dimensions.
|
||||
*/
|
||||
/* Context menu for session items */
|
||||
.chat-session-context-menu {
|
||||
position: fixed;
|
||||
box-sizing: border-box;
|
||||
width: calc(var(--space-xl) * 8 + var(--space-xs) * 2);
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
@@ -105,6 +105,26 @@ export interface ChatViewProps {
|
||||
// still preventing the composer from overtaking the message pane on short viewports.
|
||||
const CHAT_INPUT_MAX_HEIGHT_PX = 640;
|
||||
const TABLET_INPUT_MAX_HEIGHT_PX = 200;
|
||||
const CHAT_CONTEXT_MENU_FALLBACK_WIDTH_PX = 200;
|
||||
const CHAT_CONTEXT_MENU_VIEWPORT_MARGIN_PX = 8;
|
||||
|
||||
export function resolveChatContextMenuPosition(
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
anchorRight: boolean,
|
||||
menuWidth: number,
|
||||
menuHeight: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
) {
|
||||
const maximumLeft = Math.max(CHAT_CONTEXT_MENU_VIEWPORT_MARGIN_PX, viewportWidth - menuWidth - CHAT_CONTEXT_MENU_VIEWPORT_MARGIN_PX);
|
||||
const maximumTop = Math.max(CHAT_CONTEXT_MENU_VIEWPORT_MARGIN_PX, viewportHeight - menuHeight - CHAT_CONTEXT_MENU_VIEWPORT_MARGIN_PX);
|
||||
const proposedLeft = anchorRight ? anchorX - menuWidth : anchorX;
|
||||
return {
|
||||
x: Math.min(Math.max(CHAT_CONTEXT_MENU_VIEWPORT_MARGIN_PX, proposedLeft), maximumLeft),
|
||||
y: Math.min(Math.max(CHAT_CONTEXT_MENU_VIEWPORT_MARGIN_PX, anchorY), maximumTop),
|
||||
};
|
||||
}
|
||||
/** Canonical definition lives in packages/dashboard/src/chat.ts (ROOM_SKIP_SENTINEL). */
|
||||
const ROOM_SKIP_SENTINEL = "__SKIP__";
|
||||
let chatViewWasPreviouslyInactive = false;
|
||||
@@ -627,7 +647,52 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
);
|
||||
return getPersistedChatDraft(initialDraftKey);
|
||||
});
|
||||
const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{ sessionId: string; anchorX: number; anchorY: number; anchorRight: boolean; x: number; y: number } | null>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
/*
|
||||
FNXC:ChatSidebar 2026-07-17-00:12:
|
||||
FN-8191 positions each conversation-row action menu from its rendered dimensions, rather than a width derived from the default theme. This keeps the trigger edge aligned under alternate spacing themes and clamps all four actions inside both viewport axes.
|
||||
*/
|
||||
const openSessionMenu = (
|
||||
sessionId: string,
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
options?: { anchorRight?: boolean },
|
||||
) => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
setContextMenu({
|
||||
sessionId,
|
||||
anchorX,
|
||||
anchorY,
|
||||
anchorRight: options?.anchorRight ?? false,
|
||||
x: anchorX,
|
||||
y: anchorY,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!contextMenu || !contextMenuRef.current || typeof window === "undefined") return;
|
||||
|
||||
const menu = contextMenuRef.current;
|
||||
const bounds = menu.getBoundingClientRect();
|
||||
/* FNXC:ChatSidebar 2026-07-17-00:12: JSDOM has no layout, so its non-visual test fallback preserves the default-theme menu width while browsers always use rendered dimensions. */
|
||||
const width = bounds.width || menu.offsetWidth || CHAT_CONTEXT_MENU_FALLBACK_WIDTH_PX;
|
||||
const height = bounds.height || menu.offsetHeight;
|
||||
const position = resolveChatContextMenuPosition(
|
||||
contextMenu.anchorX,
|
||||
contextMenu.anchorY,
|
||||
contextMenu.anchorRight,
|
||||
width,
|
||||
height,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
);
|
||||
|
||||
if (position.x !== contextMenu.x || position.y !== contextMenu.y) {
|
||||
setContextMenu({ ...contextMenu, ...position });
|
||||
}
|
||||
}, [contextMenu]);
|
||||
const [renameDialog, setRenameDialog] = useState<{ sessionId: string; title: string } | null>(null);
|
||||
const [renameTitle, setRenameTitle] = useState("");
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
@@ -3202,7 +3267,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
onClick={() => handleSessionClick(session.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
||||
openSessionMenu(session.id, e.clientX, e.clientY);
|
||||
}}
|
||||
data-testid={`chat-session-${session.id}`}
|
||||
>
|
||||
@@ -3224,7 +3289,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
return;
|
||||
}
|
||||
const bounds = e.currentTarget.getBoundingClientRect();
|
||||
setContextMenu({ sessionId: session.id, x: bounds.right, y: bounds.bottom });
|
||||
openSessionMenu(session.id, bounds.right, bounds.bottom, { anchorRight: true });
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
@@ -3399,6 +3464,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="chat-session-context-menu"
|
||||
ref={contextMenuRef}
|
||||
role="menu"
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -1539,6 +1539,38 @@ describe("ChatView core interactions", () => {
|
||||
expect(renameSession).toHaveBeenCalledWith("session-context", "Context Renamed");
|
||||
});
|
||||
});
|
||||
|
||||
it("anchors the desktop three-dot menu's right edge under its trigger", async () => {
|
||||
const savedInnerWidth = window.innerWidth;
|
||||
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
||||
const session: ChatSessionInfo = {
|
||||
id: "session-menu-position",
|
||||
agentId: "agent-001",
|
||||
status: "active",
|
||||
title: "Desktop menu position",
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
};
|
||||
|
||||
try {
|
||||
setupMockChat({ sessions: [session], filteredSessions: [session], activeSession: session });
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const menuButton = within(screen.getByTestId("chat-session-session-menu-position")).getByTestId("chat-session-menu-btn");
|
||||
vi.spyOn(menuButton, "getBoundingClientRect").mockReturnValue({
|
||||
x: 1168, y: 48, width: 32, height: 36, top: 48, right: 1200, bottom: 84, left: 1168, toJSON: () => ({}),
|
||||
});
|
||||
await userEvent.click(menuButton);
|
||||
|
||||
const menu = document.querySelector(".chat-session-context-menu") as HTMLElement;
|
||||
const left = Number.parseFloat(menu.style.left);
|
||||
expect(left).toBe(1000);
|
||||
expect(Number.parseFloat(menu.style.top)).toBe(84);
|
||||
expect(left + 200).toBeLessThanOrEqual(window.innerWidth - 8);
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", { value: savedInnerWidth, configurable: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { ChatView } from "../ChatView";
|
||||
import { ChatView, resolveChatContextMenuPosition } from "../ChatView";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
import * as mobileScrollLock from "../../hooks/useMobileScrollLock";
|
||||
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
||||
@@ -537,6 +537,45 @@ describe("ChatView mobile behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: clamps conversation action menus using their rendered size", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const savedInnerWidth = window.innerWidth;
|
||||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||||
const sessions = [
|
||||
{ id: "session-menu-viewport", agentId: "agent-001", status: "active" as const, title: "Viewport menu", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
];
|
||||
|
||||
try {
|
||||
setupMockChat({ sessions, filteredSessions: sessions, activeSession: null });
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const row = screen.getByTestId("chat-session-session-menu-viewport");
|
||||
const menuButton = within(row).getByTestId("chat-session-menu-btn");
|
||||
vi.spyOn(menuButton, "getBoundingClientRect").mockReturnValue({
|
||||
x: 358, y: 40, width: 32, height: 36, top: 40, right: 390, bottom: 76, left: 358, toJSON: () => ({}),
|
||||
});
|
||||
|
||||
await userEvent.click(menuButton);
|
||||
let menu = document.querySelector(".chat-session-context-menu") as HTMLElement;
|
||||
let left = Number.parseFloat(menu.style.left);
|
||||
expect(left).toBeGreaterThanOrEqual(0);
|
||||
expect(left + 200).toBeLessThanOrEqual(window.innerWidth - 8);
|
||||
|
||||
fireEvent.contextMenu(row, { clientX: 388, clientY: 96 });
|
||||
menu = document.querySelector(".chat-session-context-menu") as HTMLElement;
|
||||
left = Number.parseFloat(menu.style.left);
|
||||
expect(left).toBeGreaterThanOrEqual(0);
|
||||
expect(left + 200).toBeLessThanOrEqual(window.innerWidth - 8);
|
||||
expect(Number.parseFloat(menu.style.top)).toBe(96);
|
||||
|
||||
const factoryMenuPosition = resolveChatContextMenuPosition(390, 790, true, 132, 168, 390, 800);
|
||||
expect(factoryMenuPosition).toEqual({ x: 250, y: 624 });
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", { value: savedInnerWidth, configurable: true });
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: quick session switcher rename affordance remains wired", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const selectSession = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user