diff --git a/apps/app/src/app-selection.test.ts b/apps/app/src/app-selection.test.ts
new file mode 100644
index 0000000000..1bf71559ca
--- /dev/null
+++ b/apps/app/src/app-selection.test.ts
@@ -0,0 +1,28 @@
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const css = readFileSync(
+ join(dirname(fileURLToPath(import.meta.url)), "app.css"),
+ "utf8",
+);
+const compactCss = css.replace(/\s+/g, " ");
+
+describe("app text selection policy", () => {
+ it("disables native text selection across the app shell and its portals", () => {
+ expect(css).toMatch(/body\.bb-app-shell\s*\{\s*user-select:\s*none;\s*\}/);
+ });
+
+ it("preserves native selection in editable controls", () => {
+ expect(compactCss).toContain(
+ 'body.bb-app-shell :where(input, textarea, [contenteditable]:not([contenteditable="false"])) { user-select: text; }',
+ );
+ });
+
+ it("keeps nested controls out of selectable content", () => {
+ expect(compactCss).toContain(
+ 'body.bb-app-shell .select-text :where( button, label, select, summary, [role="button"], [role="checkbox"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="radio"], [role="switch"], [role="tab"] ):not(.select-text) { user-select: none; }',
+ );
+ });
+});
diff --git a/apps/app/src/app.css b/apps/app/src/app.css
index cbd8480927..c415b551a4 100644
--- a/apps/app/src/app.css
+++ b/apps/app/src/app.css
@@ -39,6 +39,46 @@
height: 100%;
}
+ /*
+ * App chrome is interface, not document content. Keep drag gestures
+ * and Select All from sweeping through navigation, toolbars, and empty shell
+ * space; content components opt back in with the `select-text` utility.
+ */
+ body.bb-app-shell {
+ user-select: none;
+ }
+
+ /* Chromium inherits `user-select` into form controls despite the property
+ * being non-inherited by specification. Restore native selection explicitly
+ * for every editing surface inside the shell. */
+ body.bb-app-shell
+ :where(input, textarea, [contenteditable]:not([contenteditable="false"])) {
+ user-select: text;
+ }
+
+ /* A selectable content boundary may contain controls (for example, a
+ * Markdown action or a diff loader). Keep their labels out of document
+ * selections unless the control deliberately opts itself back in. */
+ body.bb-app-shell
+ .select-text
+ :where(
+ button,
+ label,
+ select,
+ summary,
+ [role="button"],
+ [role="checkbox"],
+ [role="menuitem"],
+ [role="menuitemcheckbox"],
+ [role="menuitemradio"],
+ [role="option"],
+ [role="radio"],
+ [role="switch"],
+ [role="tab"]
+ ):not(.select-text) {
+ user-select: none;
+ }
+
@media (display-mode: standalone) {
:root {
--bb-shell-height: 100lvh;
diff --git a/apps/app/src/components/AppErrorBoundary.test.tsx b/apps/app/src/components/AppErrorBoundary.test.tsx
index c10230cede..0d4f690fd1 100644
--- a/apps/app/src/components/AppErrorBoundary.test.tsx
+++ b/apps/app/src/components/AppErrorBoundary.test.tsx
@@ -50,6 +50,7 @@ describe("AppErrorBoundary", () => {
expect(container.querySelector("button")?.textContent).toBe("Reload bb");
// The message stays reachable so a report can carry it.
expect(container.textContent).toContain("render exploded");
+ expect(container.querySelector("pre")?.classList).toContain("select-text");
dispose();
});
diff --git a/apps/app/src/components/AppErrorBoundary.tsx b/apps/app/src/components/AppErrorBoundary.tsx
index 0d92ba9610..0f8672447f 100644
--- a/apps/app/src/components/AppErrorBoundary.tsx
+++ b/apps/app/src/components/AppErrorBoundary.tsx
@@ -57,7 +57,7 @@ export class AppErrorBoundary extends Component<
Error details
-
+
{error.stack ?? error.message}
diff --git a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx
index cf1e121f93..ddf8c447ca 100644
--- a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx
+++ b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx
@@ -85,6 +85,7 @@ describe("AddMachineDialog", () => {
);
const command = await screen.findByText(/--join-code jc_test123/);
+ expect(command.classList.contains("select-text")).toBe(true);
expect(sdk.plugins.callRpc).toHaveBeenCalledWith(
expect.objectContaining({
pluginId: "connect",
diff --git a/apps/app/src/components/dialogs/AddMachineDialog.tsx b/apps/app/src/components/dialogs/AddMachineDialog.tsx
index 2aaed3a8db..626157daeb 100644
--- a/apps/app/src/components/dialogs/AddMachineDialog.tsx
+++ b/apps/app/src/components/dialogs/AddMachineDialog.tsx
@@ -212,7 +212,7 @@ function AddMachineDialogContent({
) : command !== null ? (
-
+
{command}
diff --git a/apps/app/src/components/dialogs/ProviderCliInstallLogDialog.tsx b/apps/app/src/components/dialogs/ProviderCliInstallLogDialog.tsx
index e01233be5d..9890f1ab5b 100644
--- a/apps/app/src/components/dialogs/ProviderCliInstallLogDialog.tsx
+++ b/apps/app/src/components/dialogs/ProviderCliInstallLogDialog.tsx
@@ -74,7 +74,7 @@ export function ProviderCliInstallLogDialogContent({
className="absolute right-2 top-2 z-10 opacity-70 transition-opacity hover:opacity-100 focus-visible:opacity-100"
iconClassName="size-3"
/>
-
+
{state.log}
diff --git a/apps/app/src/components/git-diff/GitDiffCardBody.tsx b/apps/app/src/components/git-diff/GitDiffCardBody.tsx
index 0de3430501..d11eaeef4d 100644
--- a/apps/app/src/components/git-diff/GitDiffCardBody.tsx
+++ b/apps/app/src/components/git-diff/GitDiffCardBody.tsx
@@ -1272,7 +1272,7 @@ export function GitDiffCardBody({
return (
{shouldGateDeletedDiff ? (
diff --git a/apps/app/src/components/onboarding/OnboardingFlow.tsx b/apps/app/src/components/onboarding/OnboardingFlow.tsx
index ee3140bdc8..fcc61057d1 100644
--- a/apps/app/src/components/onboarding/OnboardingFlow.tsx
+++ b/apps/app/src/components/onboarding/OnboardingFlow.tsx
@@ -234,7 +234,7 @@ function AgentRows({
Run this in a terminal, then come back:
-
+
{agent.loginCommand}
{
);
fireEvent.click(screen.getByText("Demo board"));
expect(screen.getByText("board panel body")).toBeDefined();
+ expect(
+ screen.getByTestId("plugin-panel-body").classList.contains("select-text"),
+ ).toBe(true);
});
it("shows a plugin panel's position when it is open in a split", () => {
diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx
index 889d633fa7..2233d2374f 100644
--- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx
+++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx
@@ -912,6 +912,16 @@ describe("QueuedMessagesList", () => {
expect(container.textContent).not.toContain("`code`");
});
+ it("keeps queued draft text selectable", () => {
+ const { getByText } = renderQueuedMessages([
+ makeQueuedMessage("q_selectable", "Selectable queued draft"),
+ ]);
+
+ expect(
+ getByText("Selectable queued draft").closest(".select-text"),
+ ).not.toBeNull();
+ });
+
it("does not add hard-break elements for multi-line queued markdown", () => {
const { container } = renderQueuedMessages([
makeQueuedMessage("q_multiline", "first line\nsecond line"),
diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx
index e63e6f0538..c4bddb8644 100644
--- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx
+++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx
@@ -601,7 +601,7 @@ function QueuedMessagePreview({
return (
{markdownPreview.text.length > 0 ? (
diff --git a/apps/app/src/components/secondary-panel/FilePreview.test.tsx b/apps/app/src/components/secondary-panel/FilePreview.test.tsx
index 949845d154..86625a5894 100644
--- a/apps/app/src/components/secondary-panel/FilePreview.test.tsx
+++ b/apps/app/src/components/secondary-panel/FilePreview.test.tsx
@@ -183,6 +183,9 @@ describe("FilePreview", () => {
);
await waitFor(() => expect(pierreMock.state.statsCallback).not.toBeNull());
+ expect(
+ screen.getByTestId("pierre-file").closest(".select-text"),
+ ).not.toBeNull();
const renderCountBeforeStatsChange = Number(
screen.getByTestId("pierre-file").dataset.renderCount,
);
@@ -333,6 +336,11 @@ describe("FilePreview", () => {
expect(
screen.getByRole("table", { name: "customers.csv CSV preview" }),
).not.toBeNull();
+ expect(
+ screen
+ .getByRole("table", { name: "customers.csv CSV preview" })
+ .classList.contains("select-text"),
+ ).toBe(true);
expect(screen.getByRole("columnheader", { name: "Name" })).not.toBeNull();
expect(screen.getByRole("columnheader", { name: "Note" })).not.toBeNull();
expect(screen.getByRole("cell", { name: "Ada, Lovelace" })).not.toBeNull();
diff --git a/apps/app/src/components/secondary-panel/FilePreview.tsx b/apps/app/src/components/secondary-panel/FilePreview.tsx
index b725bfa98c..aa8879cb67 100644
--- a/apps/app/src/components/secondary-panel/FilePreview.tsx
+++ b/apps/app/src/components/secondary-panel/FilePreview.tsx
@@ -935,7 +935,7 @@ function CsvFilePreview({ file, onSelectionAddToChat }: CsvFilePreviewProps) {
see app.css overscroll notes) or scroll an ancestor. */}
@@ -1334,7 +1334,7 @@ function FilePreviewCode({
return (
({
+ useResolveThreadPendingInteraction: () => ({
+ error: null,
+ isPending: false,
+ mutateAsync: vi.fn(),
+ }),
+}));
+
+function makeInteraction(
+ payload: ApprovalPendingInteractionPayload,
+): PendingInteraction {
+ return {
+ id: "pi_test",
+ threadId: "thr_test",
+ turnId: "turn_test",
+ providerId: "codex",
+ providerThreadId: "provider-thread-test",
+ providerRequestId: "request-test",
+ status: "pending",
+ payload,
+ resolution: null,
+ statusReason: null,
+ createdAt: 1,
+ resolvedAt: null,
+ };
+}
+
+function renderInteraction(interaction: PendingInteraction) {
+ return render(
+
,
+ );
+}
+
+describe("ThreadPendingInteractionBanner", () => {
+ it("keeps command approval details selectable", () => {
+ const interaction = makeInteraction({
+ kind: "approval",
+ subject: {
+ kind: "command",
+ itemId: "item_command",
+ command: "pnpm test",
+ cwd: "/workspace/project",
+ actions: [],
+ sessionGrant: null,
+ },
+ reason: "Run the tests?",
+ availableDecisions: ["allow_once", "deny"],
+ });
+
+ renderInteraction(interaction);
+
+ expect(
+ screen.getByText("/workspace/project").closest(".select-text"),
+ ).not.toBeNull();
+ });
+
+ it("keeps file-change approval details selectable", () => {
+ const interaction = makeInteraction({
+ kind: "approval",
+ subject: {
+ kind: "file_change",
+ itemId: "item_file",
+ writeScope: "/workspace/write",
+ sessionGrant: null,
+ },
+ reason: "Write files?",
+ availableDecisions: ["allow_once", "deny"],
+ });
+
+ renderInteraction(interaction);
+
+ expect(
+ screen.getByText("Write root: /workspace/write").closest(".select-text"),
+ ).not.toBeNull();
+ });
+
+ it("keeps permission-grant approval details selectable", () => {
+ const interaction = makeInteraction({
+ kind: "approval",
+ subject: {
+ kind: "permission_grant",
+ itemId: "item_permission",
+ toolName: "WebFetch",
+ permissions: {
+ network: { enabled: true },
+ fileSystem: null,
+ },
+ },
+ reason: "Use the network?",
+ availableDecisions: ["allow_once", "deny"],
+ });
+
+ renderInteraction(interaction);
+
+ expect(
+ screen.getByText("Permission: Network access").closest(".select-text"),
+ ).not.toBeNull();
+ });
+
+ it("keeps plan file paths selectable", () => {
+ const interaction = makeInteraction({
+ kind: "approval",
+ subject: {
+ kind: "plan",
+ itemId: "item_plan",
+ plan: "# Plan\n\nShip it.",
+ planFilePath: "/workspace/plan.md",
+ },
+ reason: "Start coding?",
+ availableDecisions: ["allow_once", "deny"],
+ });
+
+ renderInteraction(interaction);
+
+ expect(
+ screen.getByText("/workspace/plan.md").closest(".select-text"),
+ ).not.toBeNull();
+ });
+});
diff --git a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx
index 39e8aac8c8..a7a57b681c 100644
--- a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx
+++ b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx
@@ -309,13 +309,13 @@ function buildApprovalSubject({
$ {command}
{detailLines.length > 0 ? (
-
+
{detailLines.map((line) => (
{line}
))}
@@ -332,7 +332,7 @@ function buildApprovalSubject({
title: payload.reason ?? "Do you want to make these changes?",
body:
detailLines.length > 0 ? (
-
+
{detailLines.map((line) => (
{line}
))}
@@ -347,7 +347,7 @@ function buildApprovalSubject({
title: payload.reason ?? "Do you want to grant this permission?",
body:
detailLines.length > 0 ? (
-
+
{detailLines.map((line) => (
{line}
))}
@@ -370,7 +370,7 @@ function buildApprovalSubject({
{planFilePath ? (
-
+
{planFilePath}
) : null}
diff --git a/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx b/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx
index c832b02ff7..0f1300d31e 100644
--- a/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx
+++ b/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx
@@ -148,6 +148,9 @@ describe("SelectableMessageProse", () => {
Selectable answer text ,
);
+ expect(
+ getByText("Selectable answer text").closest(".select-text"),
+ ).not.toBeNull();
expect(
getByText("Selectable answer text").closest(
"[data-sidebar-swipe-selectable]",
diff --git a/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx b/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx
index 889daa7b75..6e82325e3f 100644
--- a/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx
+++ b/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, type ReactNode } from "react";
+import { cn } from "@bb/shared-ui/lib/utils";
export interface SelectionAnchorPoint {
x: number;
@@ -497,7 +498,7 @@ export function SelectableMessageProse({
return (
+
{commandLine ? (
-
+
{detail}
diff --git a/apps/app/src/components/thread/timeline/ToolCallDetailBlock.tsx b/apps/app/src/components/thread/timeline/ToolCallDetailBlock.tsx
index 471b4135fb..4f7d0561cc 100644
--- a/apps/app/src/components/thread/timeline/ToolCallDetailBlock.tsx
+++ b/apps/app/src/components/thread/timeline/ToolCallDetailBlock.tsx
@@ -94,7 +94,7 @@ export function ToolCallDetailBlock({
const hasOutput = output.trim().length > 0;
return (
-
+
{
+ it("opts event output into native text selection", () => {
+ render(Selectable event output );
+
+ expect(screen.getByText("Selectable event output").classList).toContain(
+ "select-text",
+ );
+ });
+});
diff --git a/apps/app/src/components/ui/event-code-block.tsx b/apps/app/src/components/ui/event-code-block.tsx
index 5f6987e5b1..2a5565c872 100644
--- a/apps/app/src/components/ui/event-code-block.tsx
+++ b/apps/app/src/components/ui/event-code-block.tsx
@@ -22,7 +22,7 @@ export function EventCodeBlock({
return (
{
});
describe("MarkdownPreview", () => {
+ it("opts rendered content into native text selection", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.firstElementChild?.classList.contains("select-text")).toBe(
+ true,
+ );
+ });
+
it("observes content width only when the preview renders a table", () => {
const observed: Element[] = [];
class ResizeObserverMock {
diff --git a/apps/app/src/components/ui/markdown-preview.tsx b/apps/app/src/components/ui/markdown-preview.tsx
index b25ea5392f..7711f9fabc 100644
--- a/apps/app/src/components/ui/markdown-preview.tsx
+++ b/apps/app/src/components/ui/markdown-preview.tsx
@@ -1630,7 +1630,7 @@ function MarkdownPreviewComponent({
diff --git a/apps/app/src/views/PluginPanelView.tsx b/apps/app/src/views/PluginPanelView.tsx
index 0f1dd88bd3..58215b087f 100644
--- a/apps/app/src/views/PluginPanelView.tsx
+++ b/apps/app/src/views/PluginPanelView.tsx
@@ -103,7 +103,7 @@ export function PluginPanelView(props: PluginPanelViewProps = {}) {
// route padding. Plugins opt into their own padding and scrolling.
return (
{mount}