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:
gsxdsm
2026-07-17 00:17:46 -07:00
parent 0d2dcb3a40
commit f0c32dc1bf
4 changed files with 147 additions and 4 deletions

View File

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

View File

@@ -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()}

View File

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

View File

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