Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andClaude Opus 5.5 7c3c9109a0 fix(app): keep the context meter's details open after a tap
Ignoring the focus that follows a touch press applied to every hover card,
so on a wide touch screen the context window meter's details opened on the
tap and closed again after the close grace. Whether a card ignores touch is
now one decision on HoverCard, used for both the touch's pointerenter and the
focus after it. The workspace row opts in; the meter keeps opening on a tap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 20:11:05 +00:00
4cc3b69cbe test(app): fail when a tap opens the workspace hover card at all
The touch-tap test only checked that no card was left open after the tap,
which still passes when the touch's pointerenter opens the card during the
tap. That card is what makes iPad Safari drop the tap, so the test now records
whether the card mounted at any point. The trigger also reads the pointer
type from React Native's typed pointer event.

Co-authored-by: Zen <zoho.cn@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:56:00 +00:00
ZenandClaude Opus 5.5 543dc27244 fix(app): a tap on a touch screen opens a workspace row instead of its hover card
On a wide touch screen the workspace hover card opened on the tap's
pointerenter and on the focus the tap left behind. On iPad Safari that
changed the page under the finger, so the tap showed the card and never
opened the workspace. The workspace row's trigger now ignores touch, and
focus that follows a touch press no longer opens a hover card. Mouse hover
and keyboard focus open cards as before. Other triggers, such as the
context meter, which has no press of its own on wide screens, keep opening
on a touch's pointerenter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:56:00 +00:00
6 changed files with 132 additions and 9 deletions
@@ -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();
+55 -8
View File
@@ -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"