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<
             <summary className="cursor-pointer text-sm text-muted-foreground">
               Error details
             </summary>
-            <pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border p-3 text-xs">
+            <pre className="mt-2 max-h-48 select-text overflow-auto whitespace-pre-wrap break-words rounded-md border border-border p-3 text-xs">
               {error.stack ?? error.message}
             </pre>
           </details>
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({
           </div>
         ) : command !== null ? (
           <div className="space-y-2">
-            <pre className="overflow-x-auto whitespace-pre-wrap break-all rounded-md border border-border bg-muted/40 p-3 font-mono text-xs text-foreground">
+            <pre className="select-text overflow-x-auto whitespace-pre-wrap break-all rounded-md border border-border bg-muted/40 p-3 font-mono text-xs text-foreground">
               {command}
             </pre>
             <div className="flex items-center gap-2">
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"
         />
-        <pre className="max-h-80 min-h-32 overflow-auto p-3 pr-12 text-xs whitespace-pre-wrap break-words text-foreground">
+        <pre className="max-h-80 min-h-32 select-text overflow-auto whitespace-pre-wrap break-words p-3 pr-12 text-xs text-foreground">
           {state.log}
         </pre>
       </div>
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 (
     <div
       ref={bodySentinelRef}
-      className="overflow-hidden rounded-b-lg bg-background"
+      className="overflow-hidden rounded-b-lg bg-background select-text"
       style={GIT_DIFF_CARD_BODY_STYLE}
     >
       {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:
                 </p>
                 <div className="flex items-center gap-2">
-                  <code className="flex-1 truncate rounded-md border border-border bg-card px-2.5 py-1.5 font-mono text-xs">
+                  <code className="flex-1 select-text truncate rounded-md border border-border bg-card px-2.5 py-1.5 font-mono text-xs">
                     {agent.loginCommand}
                   </code>
                   <Button
diff --git a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
index c5d90709e0..57508c41a6 100644
--- a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
+++ b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
@@ -1300,6 +1300,9 @@ describe("PluginNavSidebarItems + PluginPanelView", () => {
     );
     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 (
     <div
-      className="min-w-0 flex-1 overflow-hidden text-foreground"
+      className="min-w-0 flex-1 select-text overflow-hidden text-foreground"
       title={preview}
     >
       {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. */}
         <div className="persistent-scrollbar min-h-0 overflow-auto overscroll-contain rounded-md border border-border bg-background">
           <table
-            className="min-w-full table-fixed border-separate border-spacing-0 font-mono text-xs leading-5"
+            className="min-w-full table-fixed select-text border-separate border-spacing-0 font-mono text-xs leading-5"
             aria-label={`${file.name} CSV preview`}
             style={{ width: tableWidth }}
           >
@@ -1334,7 +1334,7 @@ function FilePreviewCode({
   return (
     <div
       ref={containerRef}
-      className="min-h-0 flex-auto"
+      className="min-h-0 flex-auto select-text"
       style={FILE_PREVIEW_VIEW_STYLE}
       data-file-preview-line-number={targetLineNumber ?? undefined}
       onPointerDownCapture={lineSelectionActions.onPointerDownCapture}
diff --git a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.test.tsx b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.test.tsx
new file mode 100644
index 0000000000..36d4544fb5
--- /dev/null
+++ b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.test.tsx
@@ -0,0 +1,131 @@
+// @vitest-environment jsdom
+import { render, screen } from "@testing-library/react";
+import type {
+  ApprovalPendingInteractionPayload,
+  PendingInteraction,
+} from "@bb/domain";
+import { describe, expect, it, vi } from "vitest";
+import { ThreadPendingInteractionBanner } from "./ThreadPendingInteractionBanner";
+
+vi.mock("@/hooks/mutations/thread-interaction-mutations", () => ({
+  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(
+    <ThreadPendingInteractionBanner
+      interaction={interaction}
+      threadId={interaction.threadId}
+    />,
+  );
+}
+
+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({
             <pre
               className={cn(
                 getDetailScrollMaxHeightClass("base"),
-                "overflow-auto whitespace-pre px-3 py-2 font-mono text-xs leading-relaxed text-foreground",
+                "select-text overflow-auto whitespace-pre px-3 py-2 font-mono text-xs leading-relaxed text-foreground",
               )}
             >
               $ {command}
             </pre>
             {detailLines.length > 0 ? (
-              <ul className="border-t border-border px-3 py-2 text-xs text-muted-foreground">
+              <ul className="select-text border-t border-border px-3 py-2 text-xs text-muted-foreground">
                 {detailLines.map((line) => (
                   <li key={line}>{line}</li>
                 ))}
@@ -332,7 +332,7 @@ function buildApprovalSubject({
         title: payload.reason ?? "Do you want to make these changes?",
         body:
           detailLines.length > 0 ? (
-            <ul className="rounded-lg border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
+            <ul className="select-text rounded-lg border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
               {detailLines.map((line) => (
                 <li key={line}>{line}</li>
               ))}
@@ -347,7 +347,7 @@ function buildApprovalSubject({
         title: payload.reason ?? "Do you want to grant this permission?",
         body:
           detailLines.length > 0 ? (
-            <ul className="rounded-lg border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
+            <ul className="select-text rounded-lg border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
               {detailLines.map((line) => (
                 <li key={line}>{line}</li>
               ))}
@@ -370,7 +370,7 @@ function buildApprovalSubject({
               <MarkdownPreview content={plan} className="text-xs" />
             </div>
             {planFilePath ? (
-              <p className="truncate border-t border-border px-3 py-2 font-mono text-xs text-muted-foreground">
+              <p className="select-text truncate border-t border-border px-3 py-2 font-mono text-xs text-muted-foreground">
                 {planFilePath}
               </p>
             ) : 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", () => {
       <SelectableMessageProse>Selectable answer text</SelectableMessageProse>,
     );
 
+    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 (
     <div
       ref={nodeRef}
-      className={className}
+      className={cn("select-text", className)}
       // Let compact-sidebar swipes begin over message prose, but give an
       // expanded native text selection priority over the same touch sequence.
       data-sidebar-swipe-selectable
diff --git a/apps/app/src/components/thread/timeline/TerminalOutputBlock.tsx b/apps/app/src/components/thread/timeline/TerminalOutputBlock.tsx
index cf1edd5284..0af0a8ec71 100644
--- a/apps/app/src/components/thread/timeline/TerminalOutputBlock.tsx
+++ b/apps/app/src/components/thread/timeline/TerminalOutputBlock.tsx
@@ -133,7 +133,7 @@ export function TerminalOutputBlock({
   });
 
   return (
-    <div className="overflow-hidden rounded-lg border border-border bg-card">
+    <div className="overflow-hidden rounded-lg border border-border bg-card select-text">
       <div className="px-4 py-3 font-mono text-xs leading-tight text-foreground opacity-70">
         {commandLine ? (
           <ExpandableLine
diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
index 446cd44282..47f5a44c47 100644
--- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
+++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
@@ -1158,7 +1158,7 @@ function TimelineSystemDetailBlock({
       contentKey={detail}
       className="overflow-hidden rounded-lg border border-border bg-card"
     >
-      <pre className="whitespace-pre-wrap break-words px-4 py-3 font-mono text-xs leading-tight text-subtle-foreground opacity-70">
+      <pre className="select-text whitespace-pre-wrap break-words px-4 py-3 font-mono text-xs leading-tight text-subtle-foreground opacity-70">
         {detail}
       </pre>
     </TimelineDetailScroll>
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 (
-    <div className="overflow-hidden rounded-lg border border-border bg-card">
+    <div className="overflow-hidden rounded-lg border border-border bg-card select-text">
       <TimelineDetailScroll
         size="base"
         overflowX="hidden"
diff --git a/apps/app/src/components/ui/event-code-block.test.tsx b/apps/app/src/components/ui/event-code-block.test.tsx
new file mode 100644
index 0000000000..438c06666b
--- /dev/null
+++ b/apps/app/src/components/ui/event-code-block.test.tsx
@@ -0,0 +1,14 @@
+// @vitest-environment jsdom
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { EventCodeBlock } from "./event-code-block";
+
+describe("EventCodeBlock", () => {
+  it("opts event output into native text selection", () => {
+    render(<EventCodeBlock>Selectable event output</EventCodeBlock>);
+
+    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 (
     <pre
       className={cn(
-        "whitespace-pre-wrap break-words rounded-md px-2 py-1.5 font-mono text-xs leading-tight",
+        "select-text whitespace-pre-wrap break-words rounded-md px-2 py-1.5 font-mono text-xs leading-tight",
         tone === "danger"
           ? "text-destructive"
           : "border border-border bg-surface-raised text-muted-foreground",
diff --git a/apps/app/src/components/ui/markdown-preview.test.tsx b/apps/app/src/components/ui/markdown-preview.test.tsx
index 57198e080e..95a95be409 100644
--- a/apps/app/src/components/ui/markdown-preview.test.tsx
+++ b/apps/app/src/components/ui/markdown-preview.test.tsx
@@ -30,6 +30,16 @@ afterEach(() => {
 });
 
 describe("MarkdownPreview", () => {
+  it("opts rendered content into native text selection", () => {
+    const { container } = render(
+      <MarkdownPreview content="Selectable rendered content" />,
+    );
+
+    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({
       <div
         data-markdown-preview=""
         className={cn(
-          "max-w-none break-words text-sm leading-relaxed text-foreground",
+          "max-w-none select-text break-words text-sm leading-relaxed text-foreground",
           className,
         )}
       >
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 (
     <div
-      className="-m-4 flex min-h-0 flex-1 flex-col overflow-hidden md:-m-5"
+      className="-m-4 flex min-h-0 flex-1 select-text flex-col overflow-hidden md:-m-5"
       data-testid="plugin-panel-body"
     >
       {mount}
