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
48 changes: 48 additions & 0 deletions web/e2e/selection.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { expect, test, type Page } from "@playwright/test";

const selected = (page: Page) => page.evaluate(() => window.getSelection()?.toString() ?? "");

async function dragAcross(page: Page, selector: string) {
await page.locator(selector).first().scrollIntoViewIfNeeded();
const box = await page.locator(selector).first().boundingBox();
if (!box) throw new Error(`${selector} has no box`);
const y = box.y + Math.min(box.height / 2, 12);
await page.mouse.move(box.x + 2, y);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 2, y + Math.min(box.height - 4, 30), { steps: 8 });
await page.mouse.up();
}

test.beforeEach(async ({ page }) => {
await page.goto("/__mock/scenario/default");
await page.goto("/#/threads/t-done");
await expect(page.locator(".msg-bubble").first()).toBeVisible();
});

test("a drag across a settings page selects nothing", async ({ page }) => {
await page.goto("/#/settings/projects");
await expect(page.getByRole("heading").first()).toBeVisible();
await page.evaluate(() => window.getSelection()?.removeAllRanges());
// From the page heading to the bottom of the window, over the page's labels and rows.
const heading = await page.getByRole("heading").first().boundingBox();
if (!heading) throw new Error("no heading box");
await page.mouse.move(heading.x + 1, heading.y + heading.height / 2);
await page.mouse.down();
await page.mouse.move(heading.x + 500, 700, { steps: 12 });
await page.mouse.up();
expect(await selected(page)).toBe("");
});

test("a drag over a message selects its text", async ({ page }) => {
await page.evaluate(() => window.getSelection()?.removeAllRanges());
await dragAcross(page, ".msg-bubble");
expect((await selected(page)).trim().length).toBeGreaterThan(0);
});

test("fields stay selectable and editable", async ({ page }) => {
const field = page.getByRole("textbox", { name: /^Message/ });
await field.fill("hello there");
await field.selectText();
expect(await field.evaluate((node: HTMLTextAreaElement) => node.value.slice(node.selectionStart, node.selectionEnd))).toBe("hello there");
await expect(field).toHaveCSS("user-select", "text");
});
1 change: 1 addition & 0 deletions web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import "./styles/detailsCard.css";
import "./styles/panelTabs.css";
import "./styles/pages.css";
import "./styles/layouts.css";
import "./styles/selection.css";

captureToken();
applyTheme(useUi.getState().theme);
Expand Down
94 changes: 94 additions & 0 deletions web/src/styles/selection.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* The app is an application, not a page: dragging across its chrome selects nothing and the pointer stays an
* arrow. Text people read and copy opts back in below. user-select is not inherited past an explicit value, so an
* opted-in container keeps any inner element (a diff's line numbers) that sets none itself.
* Keep the opt-in list in sync with selection.test.ts.
*/
html,
body,
#root {
-webkit-user-select: none;
user-select: none;
cursor: default;
}

/* Transcript, markdown, code, tool output, diffs and previews. */
.msg-bubble,
.md,
.code-card,
.tool-output,
.tool-inspect,
.command,
.diff-view,
.diff-card,
.diff-comment,
.file-preview,
.plan-card-body,
.review-comment,
.review-chip-excerpt,
/* Pull requests: descriptions, comments, review text, check names and logs, patches. */
.pull-body,
.pull-comment,
.pull-review-note-text,
.pull-check-name,
.pull-patch,
.pull-mono,
/* Approval cards: what the command would run and where, and the question put to the person. */
.approval-target,
.approval-kv,
.ask-label,
.question legend,
/* Skills: a skill's description and detail text. */
.skills-description,
.skills-detail,
/* Errors and values meant to be copied. */
.error-text,
.detail-automation-error,
.pr-link-error,
.set-path,
.set-fallback-id,
.diff-path,
.browser-path,
.trust-path,
.workflow-path,
.folder-path,
.terminal-host,
/* Fields. */
input,
textarea,
[contenteditable]:not([contenteditable="false"]),
[contenteditable]:not([contenteditable="false"]) * {
-webkit-user-select: text;
user-select: text;
}

.msg-bubble,
.md,
.code-card,
.tool-output,
.tool-inspect,
.command,
.diff-view,
.diff-card,
.file-preview,
.pull-body,
.pull-comment,
.pull-patch,
.approval-target,
.approval-kv,
.error-text,
.set-path,
.diff-path,
.browser-path,
.trust-path,
.workflow-path,
.folder-path,
.skills-description,
.skills-detail,
input:not([type="checkbox"], [type="radio"], [type="button"], [type="submit"], [type="range"], [type="color"]),
textarea,
[contenteditable]:not([contenteditable="false"]) {
cursor: text;
}

/* Links and buttons keep the pointers they already have: the browser gives links a pointer and every button rule here sets its own. */
44 changes: 44 additions & 0 deletions web/src/styles/selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

const css = readFileSync(join(process.cwd(), "src/styles/selection.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
const blocks = [...css.matchAll(/([^{}]+)\{([^}]*)\}/g)].map((match) => ({
selectors: (match[1] ?? "").split(",").map((entry) => entry.trim()),
body: match[2] ?? "",
}));
const optedIn = new Set(blocks.filter(({ body }) => /(^|\s)user-select:\s*text/.test(body)).flatMap(({ selectors }) => selectors));

describe("what can be selected", () => {
it("turns selection and the text cursor off at the app root, with the WebKit spelling", () => {
const root = blocks.find(({ selectors }) => selectors.includes("#root"));
expect(root?.selectors).toEqual(expect.arrayContaining(["html", "body", "#root"]));
expect(root?.body).toMatch(/-webkit-user-select:\s*none/);
expect(root?.body).toMatch(/(^|\s)user-select:\s*none/);
expect(root?.body).toMatch(/cursor:\s*default/);
});

it("opts the text people copy back in, WebKit spelling included", () => {
for (const selector of [
".msg-bubble", ".md", ".code-card", ".tool-output", ".tool-inspect", ".command", ".diff-view", ".diff-card",
".file-preview", ".pull-body", ".pull-comment", ".pull-review-note-text", ".pull-check-name", ".pull-patch",
".error-text", ".set-path", ".terminal-host", "input", "textarea", ".approval-target", ".approval-kv",
".ask-label", ".skills-description", ".skills-detail", '[contenteditable]:not([contenteditable="false"])',
]) expect(optedIn, selector).toContain(selector);
expect(optedIn).not.toContain(".error-state");
expect(css).toMatch(/-webkit-user-select:\s*text/);
});

it("shows the text cursor over fields and the text blocks people copy, not the root's arrow", () => {
const texty = new Set(blocks.filter(({ body }) => /(^|\s)cursor:\s*text/.test(body)).flatMap(({ selectors }) => selectors));
for (const selector of ["textarea", ".command", ".diff-card", ".pull-patch", ".approval-target", ".set-path", ".msg-bubble"]) {
expect(texty, selector).toContain(selector);
}
expect([...texty].some((selector) => selector.startsWith("input:not("))).toBe(true);
expect(texty).toContain('[contenteditable]:not([contenteditable="false"])');
});

it("leaves link and button cursors alone", () => {
expect(css).not.toMatch(/cursor:\s*pointer/);
});
});
Loading