mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-13 03:45:11 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c3c9109a0 | ||
|
|
4cc3b69cbe | ||
|
|
543dc27244 |
@@ -26,6 +26,7 @@ import {
|
||||
reloadAgent,
|
||||
scriptAgentUsage,
|
||||
seedAgentWithContextWindow,
|
||||
tapContextWindowMeter,
|
||||
usageCard,
|
||||
} from "../support/helpers/context-window";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -509,6 +510,24 @@ test("context details stay open while the chat streams", async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("on a wide touch screen", () => {
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("tapping the meter opens context details that stay open", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
// A tap can request the agent's usage more than once.
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const details = await tapContextWindowMeter(page);
|
||||
// Past the card's close grace. A touch sends no pointer movement after the tap.
|
||||
await page.waitForTimeout(500);
|
||||
await expect(details).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
pinWorkspaceFromSidebar,
|
||||
scrollWorkspaceList,
|
||||
sweepPointerAcrossWorkspaceRows,
|
||||
watchWorkspaceHoverCard,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -543,6 +544,29 @@ test.describe("Sidebar workspace list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// On a wide touch screen (iPad) a tap is not a hover. Opening the card on the tap changes the page
|
||||
// under the finger, and iPad Safari then drops the tap instead of opening the workspace.
|
||||
test.describe("Sidebar workspace rows on a touch screen", () => {
|
||||
test.use({ hasTouch: true, viewport: { width: 1180, height: 820 } });
|
||||
|
||||
test("tapping a workspace row opens it without the hover card", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-touch-tap-" });
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
const hoverCard = await watchWorkspaceHoverCard(page);
|
||||
await row.tap();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
// A card that opens during the tap is what makes iPad Safari drop the tap.
|
||||
expect(await hoverCard.hasOpened()).toBe(false);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mobile sidebar panelState transition", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
|
||||
@@ -41,6 +41,14 @@ export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Pr
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Taps the meter on a touch screen and returns its details once they are on screen. */
|
||||
export async function tapContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
||||
await page.getByRole("img", { name, exact: true }).tap({ timeout: 30_000 });
|
||||
const card = contextWindowDetails(page);
|
||||
await expect(card).toBeVisible();
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Walks the pointer from the meter up onto its hover card, across the gap between them. */
|
||||
export async function moveOntoContextWindowDetails(page: Page, card: Locator): Promise<void> {
|
||||
const meter = await page.getByRole("img", { name: METER_NAME }).boundingBox();
|
||||
|
||||
@@ -132,6 +132,31 @@ export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void>
|
||||
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records whether a workspace hover card mounts at any point from now on, including one that
|
||||
* closes again before an assertion could see it.
|
||||
*/
|
||||
export async function watchWorkspaceHoverCard(
|
||||
page: Page,
|
||||
): Promise<{ hasOpened: () => Promise<boolean> }> {
|
||||
await page.evaluate(() => {
|
||||
const target = window as unknown as { __workspaceHoverCardOpened?: boolean };
|
||||
const isOpen = () => document.querySelector('[data-testid="workspace-hover-card"]') !== null;
|
||||
target.__workspaceHoverCardOpened = isOpen();
|
||||
new MutationObserver(() => {
|
||||
if (isOpen()) target.__workspaceHoverCardOpened = true;
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
return {
|
||||
hasOpened: () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __workspaceHoverCardOpened?: boolean })
|
||||
.__workspaceHoverCardOpened === true,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
|
||||
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
|
||||
|
||||
@@ -12,7 +12,14 @@ import {
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "react-native";
|
||||
import {
|
||||
Dimensions,
|
||||
View,
|
||||
type PointerEvent as ViewPointerEvent,
|
||||
type Role,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
@@ -35,7 +42,7 @@ interface HoverCardContextValue {
|
||||
setTriggerRef: (node: View | null) => void;
|
||||
triggerRef: RefObject<View | null>;
|
||||
contentRef: RefObject<View | null>;
|
||||
openNow: () => void;
|
||||
pointerEntered: (event: ViewPointerEvent) => void;
|
||||
scheduleClose: () => void;
|
||||
}
|
||||
|
||||
@@ -55,21 +62,34 @@ const HoverCardContext = createContext<HoverCardContextValue | null>(null);
|
||||
*/
|
||||
export function HoverCard({
|
||||
disabled = false,
|
||||
ignoreTouch = false,
|
||||
children,
|
||||
}: {
|
||||
/** Closes the card and keeps it closed, e.g. while its trigger is dragged. */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* The trigger handles a tap itself, so a touch does not open the card: neither the touch's
|
||||
* pointerenter nor the focus the tap leaves on the trigger. Opening during a tap changes the
|
||||
* page under the finger, and iPad Safari then drops the tap.
|
||||
*/
|
||||
ignoreTouch?: boolean;
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
if (!isWeb) return children;
|
||||
return <WebHoverCard disabled={disabled}>{children}</WebHoverCard>;
|
||||
return (
|
||||
<WebHoverCard disabled={disabled} ignoreTouch={ignoreTouch}>
|
||||
{children}
|
||||
</WebHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function WebHoverCard({
|
||||
disabled,
|
||||
ignoreTouch,
|
||||
children,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
ignoreTouch: boolean;
|
||||
children: ReactNode;
|
||||
}): ReactElement {
|
||||
const layer = useOverlayLayer("floating");
|
||||
@@ -79,6 +99,8 @@ function WebHoverCard({
|
||||
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Read when focus arrives: by focusout the element losing focus no longer matches :focus-visible.
|
||||
const keyboardFocusedRef = useRef(false);
|
||||
// Whether the latest press was a touch, so with ignoreTouch the focus that tap leaves is ignored.
|
||||
const touchPressedRef = useRef(false);
|
||||
|
||||
const clearGraceTimer = useCallback(() => {
|
||||
if (graceTimerRef.current) {
|
||||
@@ -125,6 +147,13 @@ function WebHoverCard({
|
||||
setOpen(true);
|
||||
}, [clearGraceTimer, closeNow, disabled, layer]);
|
||||
|
||||
const pointerEntered = useCallback(
|
||||
(event: ViewPointerEvent) => {
|
||||
if (!(ignoreTouch && event.nativeEvent.pointerType === "touch")) openNow();
|
||||
},
|
||||
[ignoreTouch, openNow],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return () => {
|
||||
@@ -191,7 +220,7 @@ function WebHoverCard({
|
||||
const target = event.target as Element;
|
||||
keyboardFocusedRef.current = target.matches(":focus-visible");
|
||||
if (trigger.contains(target)) {
|
||||
openNow();
|
||||
if (!(ignoreTouch && touchPressedRef.current)) openNow();
|
||||
} else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) {
|
||||
clearGraceTimer();
|
||||
}
|
||||
@@ -208,6 +237,12 @@ function WebHoverCard({
|
||||
scheduleClose();
|
||||
}
|
||||
};
|
||||
const pointerPressed = (event: PointerEvent) => {
|
||||
touchPressedRef.current = event.pointerType === "touch";
|
||||
};
|
||||
const keyPressedAnywhere = () => {
|
||||
touchPressedRef.current = false;
|
||||
};
|
||||
const activateClosedTrigger = (event: KeyboardEvent) => {
|
||||
if (
|
||||
!open &&
|
||||
@@ -221,19 +256,23 @@ function WebHoverCard({
|
||||
const scrolled = (event: Event) => {
|
||||
if ((event.target as Node).contains(trigger)) close();
|
||||
};
|
||||
document.addEventListener("pointerdown", pointerPressed, true);
|
||||
document.addEventListener("keydown", keyPressedAnywhere, true);
|
||||
document.addEventListener("focusin", focusEntered);
|
||||
document.addEventListener("focusout", focusLeft);
|
||||
trigger.addEventListener("keydown", activateClosedTrigger);
|
||||
document.addEventListener("scroll", scrolled, true);
|
||||
window.addEventListener("resize", close);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", pointerPressed, true);
|
||||
document.removeEventListener("keydown", keyPressedAnywhere, true);
|
||||
document.removeEventListener("focusin", focusEntered);
|
||||
document.removeEventListener("focusout", focusLeft);
|
||||
trigger.removeEventListener("keydown", activateClosedTrigger);
|
||||
document.removeEventListener("scroll", scrolled, true);
|
||||
window.removeEventListener("resize", close);
|
||||
};
|
||||
}, [clearGraceTimer, keyPressed, open, openNow, scheduleClose]);
|
||||
}, [clearGraceTimer, ignoreTouch, keyPressed, open, openNow, scheduleClose]);
|
||||
|
||||
// While open, the safe zone covers trigger + content + the bridge between
|
||||
// them. Close only fires when the pointer leaves the safe zone; re-entering
|
||||
@@ -256,8 +295,16 @@ function WebHoverCard({
|
||||
useEffect(() => clearGraceTimer, [clearGraceTimer]);
|
||||
|
||||
const value = useMemo<HoverCardContextValue>(
|
||||
() => ({ open, layer, setTriggerRef, triggerRef, contentRef, openNow, scheduleClose }),
|
||||
[open, layer, setTriggerRef, openNow, scheduleClose],
|
||||
() => ({
|
||||
open,
|
||||
layer,
|
||||
setTriggerRef,
|
||||
triggerRef,
|
||||
contentRef,
|
||||
pointerEntered,
|
||||
scheduleClose,
|
||||
}),
|
||||
[open, layer, setTriggerRef, pointerEntered, scheduleClose],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -285,7 +332,7 @@ export function HoverCardTrigger({
|
||||
accessibilityRole={focusable ? "button" : undefined}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
collapsable={false}
|
||||
onPointerEnter={ctx.openNow}
|
||||
onPointerEnter={ctx.pointerEntered}
|
||||
onPointerLeave={ctx.scheduleClose}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function WorkspaceHoverCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard disabled={isDragging || disabled}>
|
||||
<HoverCard disabled={isDragging || disabled} ignoreTouch>
|
||||
<HoverCardTrigger>{children}</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
placement="right"
|
||||
|
||||
Reference in New Issue
Block a user