Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
chat or terminal, under the header's: its actions (rename, split, zoom, close) are in the tab's
ellipsis menu, between the tab's panes and the tab's own actions. A split pane on a wide touch
screen keeps its own menu.
- On a phone, a long press on the chat selects text and offers copy as the phone does, instead of
opening the pane's actions menu; on the terminal it opens no menu either. A right-click, or a
menu asked for from the keyboard, still opens it.

## [0.4.7] - 2026-10-12

Expand Down
1 change: 1 addition & 0 deletions scripts/ci-browser.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ run_script scripts/prompt-dock-demo-regression.ts
run_script scripts/plugin-actions-demo-regression.ts
run_script scripts/palette-machines-demo-regression.ts
run_script scripts/narrow-pane-demo-regression.ts
run_script scripts/touch-long-press-demo-regression.ts
run_script scripts/recent-folders-regression.ts
run_script scripts/machine-dialog-regression.ts
run_script scripts/machine-conflict-regression.ts
2 changes: 1 addition & 1 deletion scripts/ci-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe("browser lane", () => {
it("summarizes every script and exits zero on success without enabling local evidence", async () => {
const { text, code, evidence } = await lane();
expect(code).toBe(0);
expect(rows(text)).toHaveLength(36);
expect(rows(text)).toHaveLength(37);
expect(rows(text).map((row) => row.script)).toContain("scripts/agy-chat-browser-qa.ts");
expect(rows(text).map((row) => row.script)).toContain("scripts/pending-pane-switch-regression.ts");
expect(rows(text).map((row) => row.script)).toContain("scripts/chat-fallback-browser-qa.ts");
Expand Down
79 changes: 79 additions & 0 deletions scripts/touch-long-press-demo-regression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { chromium, type Page } from "playwright-core";
import panes from "../site/demo/fixtures/panes.json";
import { buildDemoApp } from "./demo-build.ts";

// A long press on a pane on a touch screen is the phone's own (select text, copy): it does not
// open the pane menu. A right-click still does. On the unmodified app over the demo's fixture
// transport: no herdr session is opened.
const app = mkdtempSync(join(tmpdir(), "herdr-touch-long-press-demo-"));

/**
* Fires a contextmenu on the chat as `pointerType` would, and says whether the page took it.
* "legacy-touch": a browser whose contextmenu carries no pointer type, after a touch;
* "keyboard": Shift+F10 after that touch, a contextmenu with no pointer type either.
*/
const press = (page: Page, how: "touch" | "mouse" | "legacy-touch" | "keyboard"): Promise<boolean> => page.locator(".pane-frame.is-current .pane-frame-body").evaluate((body, how) => {
const target = body.querySelector(".chat-view, .terminal-stack") ?? body;
const rect = target.getBoundingClientRect();
const type = how === "mouse" ? "mouse" : "touch";
const at = { bubbles: true, cancelable: true, composed: true, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 3, pointerType: type, isPrimary: true };
target.dispatchEvent(new PointerEvent("pointerdown", { ...at, button: type === "mouse" ? 2 : 0, buttons: type === "mouse" ? 2 : 1 }));
target.dispatchEvent(new PointerEvent("pointerup", { ...at, button: type === "mouse" ? 2 : 0, buttons: 0 }));
if (how === "keyboard") target.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, composed: true, key: "F10", shiftKey: true }));
const event = how === "touch" || how === "mouse" ? new PointerEvent("contextmenu", { ...at, button: 2 }) : new MouseEvent("contextmenu", { ...at, button: 2 });
target.dispatchEvent(event);
return event.defaultPrevented;
}, how);

try {
await buildDemoApp(app);
const index = join(app, "index.html");
writeFileSync(index, readFileSync(index, "utf8").replace(/<script type="module"/, '<script src="./demo-transport.js"></script>\n <script type="module"'));
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) {
const path = new URL(request.url).pathname;
if (!path.startsWith("/herdr-web-ui/demo/app/")) return new Response("not found", { status: 404 });
let file = decodeURIComponent(path.slice("/herdr-web-ui/demo/app/".length));
if (!file || file.endsWith("/")) file += "index.html";
if (file.split("/").includes("..") || file.includes("\\")) return new Response("bad path", { status: 400 });
const body = Bun.file(join(app, file));
return (await body.exists()) ? new Response(body) : new Response("not found", { status: 404 });
} });
try {
const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] });
try {
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: "en-US" });
await context.addInitScript(() => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", defaultView: "chat" })));
const page = await context.newPage();
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(`http://127.0.0.1:${server.port}/herdr-web-ui/demo/app/?pane=${encodeURIComponent(panes.infra)}&view=chat`);
await page.locator(".conn-live").waitFor({ state: "attached" });
await page.locator(".pane-frame.is-current").waitFor();
const paneMenu = page.getByRole("dialog", { name: /^Pane actions for / });

assert.equal(await press(page, "touch"), false, "a touch long press keeps the phone's own selection and copy");
await page.waitForTimeout(300);
assert.equal(await paneMenu.count(), 0, "a touch long press opens no pane menu");

assert.equal(await press(page, "legacy-touch"), false, "a contextmenu with no pointer type right after a touch is the touch's");
await page.waitForTimeout(300);
assert.equal(await paneMenu.count(), 0, "it opens no pane menu either");

assert.equal(await press(page, "keyboard"), true, "a menu asked for from the keyboard after a touch is the app's");
await paneMenu.waitFor();
await page.keyboard.press("Escape");
await paneMenu.waitFor({ state: "detached" });

assert.equal(await press(page, "mouse"), true, "a right-click is still the app's");
await paneMenu.waitFor();
assert.deepEqual(errors, []);
console.log("PASS a touch long press on a pane is the phone's own; the keyboard and a right-click still open the pane menu");
} finally { await browser.close(); }
} finally { await server.stop(true); }
} finally {
rmSync(app, { recursive: true, force: true });
}
7 changes: 7 additions & 0 deletions src/components/PaneCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ export function PaneCanvas({ snapshot, pane, selectedPaneId, onSelect, onChanged
const sendsRightClick = (target: HerdrPane, event: MouseEvent<HTMLElement>): boolean =>
passthrough.has(target.pane_id) && isTerminal(target) && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey
&& event.target instanceof Element && !!event.target.closest('.pane-terminal[data-mouse-reporting]');
// a long press on a touch screen is the phone's own: select text, copy (the tab's menu has the pane's actions)
const touchPress = useRef(false);
const context = (event: MouseEvent<HTMLElement>, target: HerdrPane): void => {
const pointer = (event.nativeEvent as Partial<globalThis.PointerEvent>).pointerType;
if (pointer === "touch" || pointer === "pen" || (pointer === undefined && touchPress.current)) return;
event.preventDefault();
event.stopPropagation();
if (sendsRightClick(target, event)) return;
Expand Down Expand Up @@ -236,7 +240,10 @@ export function PaneCanvas({ snapshot, pane, selectedPaneId, onSelect, onChanged
aria-label={displayPaneTitle(item)} aria-current={active ? "true" : undefined} aria-hidden={hidden || undefined} tabIndex={-1}
style={alone === item.pane_id || (stacked && !cell.retained) ? { left: 0, top: 0, width: "100%", height: "100%" }
: { left: `${cell.left}%`, top: `${cell.top}%`, width: `${cell.width}%`, height: `${cell.height}%` }}
// a menu asked for from the keyboard (Shift+F10, the Menu key) comes with no pointer: the last touch is not it
onKeyDownCapture={() => { touchPress.current = false; }}
onPointerDownCapture={(event) => {
touchPress.current = event.pointerType === "touch" || event.pointerType === "pen";
// A right press must not change focus before the menu's Swap with focused pane.
if (event.button === 0 && !paneContextPress(event, apple) && !active) {
onSelect(item.pane_id, false);
Expand Down
Loading