mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-09-28 05:54:50 +08:00
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:
co-authored by
Cursor
hieptl
parent
eb85ed4566
commit
b7e325e998
@@ -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);
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+15
-22
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user