fix: make overflow menus usable on touch devices (#16101)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
FraterCCCLXIII
2026-08-10 15:41:46 +07:00
committed by GitHub
co-authored by Cursor hieptl
parent eb85ed4566
commit b7e325e998
14 changed files with 602 additions and 52 deletions
@@ -312,6 +312,54 @@ describe("ConversationCard", () => {
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
});
it("keeps the ellipsis clickable without hover via touch-first reveal classes", () => {
renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen={false}
onContextMenuToggle={vi.fn()}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
const actionOverlay = ellipsisButton.parentElement;
expect(actionOverlay).toHaveClass("pointer-events-auto");
expect(actionOverlay?.className).toContain(
"[@media(hover:hover)_and_(pointer:fine)]:pointer-events-none",
);
});
it("closes the context menu when clicking outside", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<div>
<div data-testid="outside">Outside</div>
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>
</div>,
);
expect(screen.getByTestId("context-menu")).toBeInTheDocument();
await user.click(screen.getByTestId("outside"));
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
});
it("should call onDelete when the delete button is clicked", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
@@ -0,0 +1,168 @@
import { fireEvent, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { ConversationNameWithStatus } from "#/components/features/conversation/conversation-name-with-status";
import { AgentState } from "#/types/agent-state";
import { ExecutionStatus } from "#/types/agent-server/core/base/common";
import { useAgentState } from "#/hooks/use-agent-state";
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
vi.mock("#/hooks/query/use-task-polling", () => ({
useTaskPolling: () => ({
isTask: false,
taskStatus: null,
}),
}));
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({
data: {
id: "test-conversation-id",
title: "Test conversation",
execution_status: ExecutionStatus.RUNNING,
},
}),
}));
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({ conversationId: "test-conversation-id" }),
}));
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
useUnifiedPauseConversation: () => ({ mutate: vi.fn() }),
}));
vi.mock("#/hooks/mutation/use-unified-start-conversation", () => ({
useUnifiedResumeConversation: () => ({ mutate: vi.fn() }),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({ providers: [] }),
}));
vi.mock("#/components/features/conversation/conversation-name", () => ({
ConversationName: () => <div data-testid="conversation-name" />,
}));
vi.mock("#/components/features/conversation/right-panel-toggle", () => ({
RightPanelToggle: () => null,
}));
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: () => Promise.resolve() },
}),
};
});
describe("ConversationNameWithStatus", () => {
beforeEach(() => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.RUNNING,
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("opens the server status menu on click and closes on outside click", async () => {
const user = userEvent.setup();
renderWithProviders(
<div>
<div data-testid="outside">Outside</div>
<ConversationNameWithStatus />
</div>,
);
expect(
screen.queryByTestId("server-status-context-menu"),
).not.toBeInTheDocument();
// fireEvent click avoids a preceding mouse pointerenter (hover-open), which
// models the touch/toggle path this assertion covers.
fireEvent.click(screen.getByTestId("server-status-menu-trigger"));
expect(
screen.getByTestId("server-status-context-menu"),
).toBeInTheDocument();
await user.click(screen.getByTestId("outside"));
expect(
screen.queryByTestId("server-status-context-menu"),
).not.toBeInTheDocument();
});
it("toggles the server status menu closed when the trigger is clicked again", () => {
renderWithProviders(<ConversationNameWithStatus />);
const trigger = screen.getByTestId("server-status-menu-trigger");
fireEvent.click(trigger);
expect(
screen.getByTestId("server-status-context-menu"),
).toBeInTheDocument();
fireEvent.click(trigger);
expect(
screen.queryByTestId("server-status-context-menu"),
).not.toBeInTheDocument();
});
it("opens on mouse pointerenter and dismisses on a following mouse click", () => {
renderWithProviders(<ConversationNameWithStatus />);
const trigger = screen.getByTestId("server-status-menu-trigger");
const hoverTarget = trigger.parentElement;
expect(hoverTarget).not.toBeNull();
// pointerover is the event React delegates for onPointerEnter synthesis,
// so it reaches the handler reliably across jsdom setups.
fireEvent.pointerOver(hoverTarget!, { pointerType: "mouse" });
expect(
screen.getByTestId("server-status-context-menu"),
).toBeInTheDocument();
fireEvent.click(trigger);
expect(
screen.queryByTestId("server-status-context-menu"),
).not.toBeInTheDocument();
});
it("keeps click-toggle open after touch/compatibility hover events on fine-hover hardware", () => {
// Device-primary pointer reports fine hover, but the interaction is touch.
vi.stubGlobal(
"matchMedia",
vi.fn().mockReturnValue({
matches: true,
media: "(hover: hover) and (pointer: fine)",
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
);
renderWithProviders(<ConversationNameWithStatus />);
const trigger = screen.getByTestId("server-status-menu-trigger");
const hoverTarget = trigger.parentElement;
expect(hoverTarget).not.toBeNull();
// Compatibility mouseenter must not open-then-close against the tap.
fireEvent.mouseEnter(hoverTarget!);
fireEvent.pointerOver(hoverTarget!, { pointerType: "touch" });
fireEvent.click(trigger);
expect(
screen.getByTestId("server-status-context-menu"),
).toBeInTheDocument();
});
});
@@ -196,6 +196,26 @@ describe("ProfileActionsMenu", () => {
expect(handleClose).toHaveBeenCalledTimes(1);
});
it("does not call onClose when mousedown lands on the anchor trigger", () => {
const handleClose = vi.fn();
const anchorRef = { current: document.createElement("button") };
anchorRef.current.setAttribute("data-testid", "profile-menu-trigger");
document.body.appendChild(anchorRef.current);
render(
<ProfileActionsMenu
{...defaultProps}
onClose={handleClose}
anchorRef={anchorRef}
/>,
);
fireEvent.mouseDown(anchorRef.current);
expect(handleClose).not.toHaveBeenCalled();
anchorRef.current.remove();
});
it("calls onClose when Escape key is pressed", () => {
const handleClose = vi.fn();
@@ -1,3 +1,4 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
@@ -34,3 +35,36 @@ test("call the callback when the element is clicked outside", async () => {
await user.click(outsideElement);
expect(callback).toHaveBeenCalled();
});
test("does not call the callback when clicking the ignored trigger", async () => {
const user = userEvent.setup();
const callback = vi.fn();
function Harness() {
const ignoreOutsideClickRef = React.useRef<HTMLButtonElement>(null);
const ref = useClickOutsideElement<HTMLDivElement>(
callback,
ignoreOutsideClickRef,
);
return (
<div>
<div data-testid="inside-element" ref={ref} />
<button
ref={ignoreOutsideClickRef}
type="button"
data-testid="ignored-trigger"
/>
<div data-testid="outside-element" />
</div>
);
}
render(<Harness />);
await user.click(screen.getByTestId("ignored-trigger"));
expect(callback).not.toHaveBeenCalled();
await user.click(screen.getByTestId("outside-element"));
expect(callback).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import path from "node:path";
import { compile } from "tailwindcss";
import {
FINE_HOVER_ACTION_CLASSES,
FINE_HOVER_PINNED_TIMESTAMP_CLASSES,
FINE_HOVER_RESERVE_CLASSES,
FINE_HOVER_YIELD_CLASSES,
hoverRevealActionClassName,
hoverRevealPinnedTimestampClassName,
hoverRevealReserveClassName,
hoverRevealYieldClassName,
} from "#/utils/hover-reveal-classes";
const require = createRequire(import.meta.url);
const HOVER_REVEAL_SOURCE_PATH = path.resolve(
process.cwd(),
"src/utils/hover-reveal-classes.ts",
);
async function loadStylesheet(id: string, base: string) {
if (id === "tailwindcss") {
const resolved = require.resolve("tailwindcss/index.css");
return {
path: resolved,
base: path.dirname(resolved),
content: readFileSync(resolved, "utf8"),
};
}
const resolved = path.resolve(base, id);
return {
path: resolved,
base: path.dirname(resolved),
content: readFileSync(resolved, "utf8"),
};
}
describe("hover-reveal-classes", () => {
it("keeps overflow actions visible by default and only hides them under fine-hover media", () => {
const classes = hoverRevealActionClassName();
expect(classes).toContain("pointer-events-auto");
expect(classes).toContain("opacity-100");
for (const candidate of FINE_HOVER_ACTION_CLASSES) {
expect(classes).toContain(candidate);
}
});
it("force-visible actions stay interactable without fine-hover hiding", () => {
expect(hoverRevealActionClassName(true)).toBe(
"pointer-events-auto visible opacity-100",
);
});
it("yields timestamp space on touch and restores it under fine-hover media", () => {
const classes = hoverRevealYieldClassName();
expect(classes).toContain("opacity-0");
for (const candidate of FINE_HOVER_YIELD_CLASSES) {
expect(classes).toContain(candidate);
}
});
it("reserves action width on touch and collapses it under fine-hover media", () => {
const classes = hoverRevealReserveClassName();
expect(classes).toContain("min-w-[3.75rem]");
for (const candidate of FINE_HOVER_RESERVE_CLASSES) {
expect(classes).toContain(candidate);
}
});
it("hides the pinned-card timestamp under the ellipsis on touch", () => {
const classes = hoverRevealPinnedTimestampClassName();
expect(classes).toContain("hidden");
for (const candidate of FINE_HOVER_PINNED_TIMESTAMP_CLASSES) {
expect(classes).toContain(candidate);
}
});
it("keeps fine-hover candidates as complete source literals for Tailwind scanning", () => {
const source = readFileSync(HOVER_REVEAL_SOURCE_PATH, "utf8");
const candidates = [
...FINE_HOVER_ACTION_CLASSES,
...FINE_HOVER_YIELD_CLASSES,
...FINE_HOVER_RESERVE_CLASSES,
...FINE_HOVER_PINNED_TIMESTAMP_CLASSES,
];
for (const candidate of candidates) {
expect(source).toContain(`"${candidate}"`);
}
});
it("emits fine-hover media rules into compiled CSS for the literal candidates", async () => {
const candidates = [
...FINE_HOVER_ACTION_CLASSES,
...FINE_HOVER_YIELD_CLASSES,
...FINE_HOVER_RESERVE_CLASSES,
...FINE_HOVER_PINNED_TIMESTAMP_CLASSES,
];
const { build } = await compile('@import "tailwindcss" source(none);', {
base: process.cwd(),
loadStylesheet,
});
const css = build([...candidates]);
expect(css).toContain("@media (hover:hover) and (pointer:fine)");
expect(css).toContain("pointer-events: none");
expect(css).toContain("pointer-events: auto");
expect(css).toContain("opacity: 0");
expect(css).toContain("min-width: 3.75rem");
});
});
@@ -1,3 +1,4 @@
import type { MouseEvent, RefObject } from "react";
import { useTranslation } from "react-i18next";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { ContextMenu } from "#/ui/context-menu";
@@ -13,12 +14,13 @@ import { isExecutionActive, isExecutionPaused } from "#/utils/status";
interface ServerStatusContextMenuProps {
onClose: () => void;
onStopServer?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onStartServer?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onStopServer?: (event: MouseEvent<HTMLButtonElement>) => void;
onStartServer?: (event: MouseEvent<HTMLButtonElement>) => void;
executionStatus: ExecutionStatus | null;
position?: "top" | "bottom";
className?: string;
isPausing?: boolean;
ignoreOutsideClickRef?: RefObject<HTMLElement | null>;
}
export function ServerStatusContextMenu({
@@ -29,9 +31,13 @@ export function ServerStatusContextMenu({
position = "top",
className = "",
isPausing = false,
ignoreOutsideClickRef,
}: ServerStatusContextMenuProps) {
const { t } = useTranslation("openhands");
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const ref = useClickOutsideElement<HTMLUListElement>(
onClose,
ignoreOutsideClickRef,
);
const isActive = isExecutionActive(executionStatus);
const isPaused = isExecutionPaused(executionStatus);
@@ -1,6 +1,8 @@
import React, { useLayoutEffect, useReducer, useRef } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { ExecutionStatus } from "#/types/agent-server/core/base/common";
import { isExecutionActive, isExecutionPaused } from "#/utils/status";
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
@@ -35,6 +37,7 @@ export function ConversationCardActions({
conversationId,
showOptions,
}: ConversationCardActionsProps) {
const { t } = useTranslation("openhands");
const isPaused = isExecutionPaused(executionStatus);
const isActive = isExecutionActive(executionStatus);
const anchorRef = useRef<HTMLButtonElement>(null);
@@ -101,6 +104,7 @@ export function ConversationCardActions({
event.stopPropagation();
onContextMenuToggle(!contextMenuOpen);
}}
ariaLabel={t(I18nKey.COMMON$MORE_OPTIONS)}
className={cn(isPaused && "opacity-60")}
/>
{contextMenuOpen && floatingStyle && portalTarget
@@ -11,6 +11,12 @@ import { ExecutionStatus } from "#/types/agent-server/core/base/common";
import { SandboxStatus } from "#/api/conversation-service/agent-server-conversation-service.types";
import { RepositorySelection } from "#/api/open-hands.types";
import { formatTimeDelta } from "#/utils/format-time-delta";
import {
hoverRevealActionClassName,
hoverRevealPinnedTimestampClassName,
hoverRevealReserveClassName,
hoverRevealYieldClassName,
} from "#/utils/hover-reveal-classes";
import { ConversationCardHeader } from "./conversation-card-header";
import { ConversationCardActions } from "./conversation-card-actions";
import { ConversationCardFooter } from "./conversation-card-footer";
@@ -250,21 +256,19 @@ export function ConversationCard({
// The hover action overlay (pin + ellipsis) is absolutely
// positioned, so reserve its width so the flex-1 title truncates
// instead of colliding with the buttons. Pinned cards keep the pin
// visible at rest, so reserve the width always for those.
// visible at rest, so reserve the width always for those. Touch /
// coarse-pointer devices also keep the reserve so the ellipsis stays
// clickable without a hover pass.
showPersistentPinIcon
? "min-w-[3.75rem]"
: hasHoverActions &&
"group-hover:min-w-[3.75rem] group-focus-within:min-w-[3.75rem]",
contextMenuOpen && "min-w-[3.75rem]",
: hasHoverActions && hoverRevealReserveClassName(contextMenuOpen),
)}
>
{!showPersistentPinIcon && (createdAt ?? lastUpdatedAt) && (
<p
className={cn(
"text-xs text-[var(--oh-muted)] text-right whitespace-nowrap transition-opacity -translate-x-1.5",
hasHoverActions &&
"group-hover:opacity-0 group-focus-within:opacity-0",
contextMenuOpen && "opacity-0",
hasHoverActions && hoverRevealYieldClassName(contextMenuOpen),
)}
>
<time>{formatTimeDelta(lastUpdatedAt ?? createdAt)}</time>
@@ -277,12 +281,7 @@ export function ConversationCard({
"absolute right-0 top-1/2 flex -translate-y-1/2 items-center gap-0.5 transition-opacity",
showPersistentPinIcon
? "pointer-events-auto visible opacity-100"
: cn(
"pointer-events-none opacity-0 invisible",
"group-hover:pointer-events-auto group-hover:opacity-100 group-hover:visible",
"group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-focus-within:visible",
),
contextMenuOpen && "pointer-events-auto visible opacity-100",
: hoverRevealActionClassName(contextMenuOpen),
)}
>
{onTogglePin ? renderPinButton() : null}
@@ -290,12 +289,7 @@ export function ConversationCard({
(createdAt ?? lastUpdatedAt) &&
hasContextMenu ? (
<div className="relative shrink-0">
<div
className={cn(
!contextMenuOpen &&
"invisible pointer-events-none group-hover:visible group-hover:pointer-events-auto group-focus-within:visible group-focus-within:pointer-events-auto",
)}
>
<div className={hoverRevealActionClassName(contextMenuOpen)}>
<ConversationCardActions
contextMenuOpen={contextMenuOpen}
onContextMenuToggle={onContextMenuToggle || (() => {})}
@@ -313,10 +307,9 @@ export function ConversationCard({
</div>
<p
className={cn(
"pointer-events-none absolute inset-0 flex items-center justify-end",
"pointer-events-none absolute inset-0 items-center justify-end",
"text-xs text-[var(--oh-muted)] whitespace-nowrap -translate-x-1.5",
"group-hover:hidden group-focus-within:hidden",
contextMenuOpen && "hidden",
hoverRevealPinnedTimestampClassName(contextMenuOpen),
)}
>
<time>{formatTimeDelta(lastUpdatedAt ?? createdAt)}</time>
@@ -111,7 +111,7 @@ export function ConversationNameContextMenu({
const { backend } = useActiveBackend();
const { data: conversation } = useActiveConversation();
const isArchivedConversation = useIsArchivedConversation();
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const ref = useClickOutsideElement<HTMLUListElement>(onClose, anchorRef);
const anchorElement = anchorRef?.current ?? null;
const [portalStyle, setPortalStyle] = React.useState<React.CSSProperties>();
@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useAgentState } from "#/hooks/use-agent-state";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
@@ -6,7 +7,7 @@ import { useUnifiedPauseConversation } from "#/hooks/mutation/use-unified-stop-c
import { useUnifiedResumeConversation } from "#/hooks/mutation/use-unified-start-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useUserProviders } from "#/hooks/use-user-providers";
import { getStatusColor } from "#/utils/utils";
import { getStatusColor, cn } from "#/utils/utils";
import { AgentState } from "#/types/agent-state";
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
import { ServerStatusContextMenu } from "../controls/server-status-context-menu";
@@ -17,8 +18,10 @@ import {
isExecutionErrored,
isExecutionPaused,
} from "#/utils/status";
import { I18nKey } from "#/i18n/declaration";
export function ConversationNameWithStatus() {
const { t } = useTranslation("openhands");
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const { curAgentState } = useAgentState();
@@ -26,6 +29,9 @@ export function ConversationNameWithStatus() {
const { mutate: pauseConversation } = useUnifiedPauseConversation();
const { mutate: resumeConversation } = useUnifiedResumeConversation();
const { providers } = useUserProviders();
const [menuOpen, setMenuOpen] = React.useState(false);
const [hoveredOpen, setHoveredOpen] = React.useState(false);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const executionStatus = conversation?.execution_status ?? null;
const isStartingStatus =
@@ -41,12 +47,20 @@ export function ConversationNameWithStatus() {
curAgentState,
});
const isMenuVisible = menuOpen || hoveredOpen;
const closeMenu = () => {
setMenuOpen(false);
setHoveredOpen(false);
};
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (conversationId) {
pauseConversation({ conversationId });
}
closeMenu();
};
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -55,29 +69,78 @@ export function ConversationNameWithStatus() {
if (conversationId) {
resumeConversation({ conversationId, providers });
}
closeMenu();
};
const handleStatusClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
// Click toggles for touch; on desktop it also dismisses a hover-opened menu
// instead of "pinning" it open under the cursor.
if (isMenuVisible) {
closeMenu();
return;
}
setMenuOpen(true);
};
return (
<div className="flex items-center justify-between w-full">
<div className="flex items-center min-w-0">
<div className="group relative shrink-0">
<DebugStackframeDot
className="ml-[3.5px] w-6 h-6 cursor-pointer"
color={statusColor}
/>
<ServerStatusContextMenu
onClose={() => {}}
onStopServer={
isExecutionActive(executionStatus) ? handleStopServer : undefined
<div
className="relative shrink-0"
onPointerEnter={(event) => {
// Gate on this event's pointer, not device-primary matchMedia — hybrid
// laptops can emit compatibility mouseenter for touchscreen taps.
if (event.pointerType === "mouse") {
setHoveredOpen(true);
}
onStartServer={
isExecutionPaused(executionStatus) ? handleStartServer : undefined
}}
onPointerLeave={(event) => {
if (event.pointerType === "mouse") {
setHoveredOpen(false);
}
executionStatus={executionStatus}
position="bottom"
className="opacity-0 invisible pointer-events-none group-hover:opacity-100 group-hover:visible group-hover:pointer-events-auto bottom-full left-0 mt-0 min-h-fit"
isPausing={false}
/>
}}
>
<button
ref={triggerRef}
type="button"
data-testid="server-status-menu-trigger"
aria-label={t(I18nKey.COMMON$SERVER_STATUS)}
aria-expanded={isMenuVisible}
aria-haspopup="menu"
onClick={handleStatusClick}
className={cn(
"flex items-center justify-center rounded-md",
"text-[var(--oh-muted)] hover:bg-white/10",
)}
>
<DebugStackframeDot
className="ml-[3.5px] w-6 h-6 cursor-pointer"
color={statusColor}
aria-hidden
/>
</button>
{isMenuVisible ? (
<ServerStatusContextMenu
onClose={closeMenu}
ignoreOutsideClickRef={triggerRef}
onStopServer={
isExecutionActive(executionStatus)
? handleStopServer
: undefined
}
onStartServer={
isExecutionPaused(executionStatus)
? handleStartServer
: undefined
}
executionStatus={executionStatus}
position="bottom"
className="bottom-full left-0 mt-0 min-h-fit"
isPausing={false}
/>
) : null}
</div>
<ConversationName />
</div>
@@ -176,7 +176,10 @@ export function ConversationName() {
ref={ellipsisAnchorRef}
className="relative flex items-center shrink-0"
>
<EllipsisButton onClick={handleEllipsisClick} />
<EllipsisButton
onClick={handleEllipsisClick}
ariaLabel={t(I18nKey.COMMON$MORE_OPTIONS)}
/>
{contextMenuOpen && (
<ConversationNameContextMenu
onClose={() => setContextMenuOpen(false)}
@@ -78,9 +78,9 @@ export function AgentProfileActionsMenu({
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (menuRef.current && !menuRef.current.contains(target)) {
onClose();
}
if (menuRef.current?.contains(target)) return;
if (anchorElement?.contains(target)) return;
onClose();
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
@@ -91,7 +91,7 @@ export function AgentProfileActionsMenu({
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
}, [anchorElement, onClose]);
const handleAction = (action: () => void) => {
action();
@@ -85,9 +85,9 @@ export function ProfileActionsMenu({
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (menuRef.current && !menuRef.current.contains(target)) {
onClose();
}
if (menuRef.current?.contains(target)) return;
if (anchorElement?.contains(target)) return;
onClose();
};
const handleEscape = (event: KeyboardEvent) => {
@@ -103,7 +103,7 @@ export function ProfileActionsMenu({
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
}, [anchorElement, onClose]);
const handleAction = (action: () => void) => {
action();
+94
View File
@@ -0,0 +1,94 @@
import { cn } from "#/utils/utils";
/**
* Full Tailwind candidates for fine-hover media — devices where CSS `:hover`
* is a reliable primary interaction (mouse/trackpad). Coarse-pointer /
* touch-primary devices match the inverse and keep overflow actions always
* visible + clickable. Kept as complete string literals so the production CSS
* scanner can emit the arbitrary-variant rules (dynamic prefix concatenation
* is not discoverable).
*/
export const FINE_HOVER_ACTION_CLASSES = [
"[@media(hover:hover)_and_(pointer:fine)]:pointer-events-none",
"[@media(hover:hover)_and_(pointer:fine)]:invisible",
"[@media(hover:hover)_and_(pointer:fine)]:opacity-0",
"[@media(hover:hover)_and_(pointer:fine)]:group-hover:pointer-events-auto",
"[@media(hover:hover)_and_(pointer:fine)]:group-hover:visible",
"[@media(hover:hover)_and_(pointer:fine)]:group-hover:opacity-100",
"[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:pointer-events-auto",
"[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:visible",
"[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:opacity-100",
] as const;
export const FINE_HOVER_YIELD_CLASSES = [
"[@media(hover:hover)_and_(pointer:fine)]:opacity-100",
"[@media(hover:hover)_and_(pointer:fine)]:group-hover:opacity-0",
"[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:opacity-0",
] as const;
export const FINE_HOVER_RESERVE_CLASSES = [
"[@media(hover:hover)_and_(pointer:fine)]:min-w-0",
"[@media(hover:hover)_and_(pointer:fine)]:group-hover:min-w-[3.75rem]",
"[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:min-w-[3.75rem]",
] as const;
export const FINE_HOVER_PINNED_TIMESTAMP_CLASSES = [
"[@media(hover:hover)_and_(pointer:fine)]:flex",
"[@media(hover:hover)_and_(pointer:fine)]:group-hover:hidden",
"[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:hidden",
] as const;
/**
* Overlay action chrome (ellipsis, pin, etc.): always interactable on touch;
* hover/focus-reveal only on fine-pointer hover devices.
*/
export function hoverRevealActionClassName(forceVisible = false): string {
if (forceVisible) {
return "pointer-events-auto visible opacity-100";
}
return cn(
"pointer-events-auto visible opacity-100",
...FINE_HOVER_ACTION_CLASSES,
);
}
/**
* Companion for timestamps that yield space to hover-reveal actions:
* hidden on touch (actions stay visible); on fine-pointer devices, visible
* until the row is hovered / focused / menu-open.
*/
export function hoverRevealYieldClassName(forceHidden = false): string {
if (forceHidden) {
return "opacity-0";
}
return cn("opacity-0", ...FINE_HOVER_YIELD_CLASSES);
}
/**
* Reserve trailing space for hover-reveal actions. Always reserved on touch;
* on fine-pointer devices, reserved on hover / focus / open.
*/
export function hoverRevealReserveClassName(forceReserved = false): string {
if (forceReserved) {
return "min-w-[3.75rem]";
}
return cn("min-w-[3.75rem]", ...FINE_HOVER_RESERVE_CLASSES);
}
/**
* Absolute-positioned timestamp that sits under a pinned-card ellipsis slot:
* shown at rest on fine-pointer devices, hidden when the row reveals actions
* (or when the menu is open / on touch where the ellipsis stays visible).
*/
export function hoverRevealPinnedTimestampClassName(
forceHidden = false,
): string {
if (forceHidden) {
return "hidden";
}
return cn("hidden", ...FINE_HOVER_PINNED_TIMESTAMP_CLASSES);
}