diff --git a/surfaces/gui/e2e/chat.spec.ts b/surfaces/gui/e2e/chat.spec.ts
index b7b42d61..b40345a8 100644
--- a/surfaces/gui/e2e/chat.spec.ts
+++ b/surfaces/gui/e2e/chat.spec.ts
@@ -57,3 +57,37 @@ test("approval: Deny skips the tool and the agent says so", async ({ page }) =>
await page.getByRole("button", { name: "Deny" }).last().click();
await expect(page.getByText("Understood — skipped the command.")).toBeVisible();
});
+
+test("long user pastes clamp with a more…/less… toggle", async ({ page }) => {
+ await page.goto("/");
+ const box = page.getByPlaceholder(/Ask the coworker/);
+ await expect(box).toBeVisible();
+
+ const tail = "END-OF-PASTE-MARKER";
+ const paste =
+ "reply OK. " + "lorem ipsum dolor sit amet consectetur ".repeat(60) + tail; // ~2.4k chars
+ await box.fill(paste);
+ await page.getByRole("button", { name: "Send" }).click();
+
+ // Clamped: the bubble shows the head but not the tail, plus the toggle.
+ const more = page.getByRole("button", { name: "more…" });
+ await expect(more).toBeVisible();
+ const bubble = page.locator(".bubble-user").last();
+ await expect(bubble).toContainText("reply OK.");
+ await expect(bubble).not.toContainText(tail);
+
+ // Expand → full text + "less…"; collapse → clamped again.
+ await more.click();
+ await expect(bubble).toContainText(tail);
+ const less = page.getByRole("button", { name: "less…" });
+ await expect(less).toBeVisible();
+ await less.click();
+ await expect(bubble).not.toContainText(tail);
+
+ // Short messages never show the control.
+ await expect(page.getByText("Echo:").first()).toBeVisible();
+ await box.fill("short follow-up");
+ await page.getByRole("button", { name: "Send" }).click();
+ await expect(page.getByText("short follow-up", { exact: true }).first()).toBeVisible();
+ await expect(page.getByRole("button", { name: "more…" })).toHaveCount(1); // still only the paste's
+});
diff --git a/surfaces/gui/src/components/Transcript.tsx b/surfaces/gui/src/components/Transcript.tsx
index c6c95be1..2591a275 100644
--- a/surfaces/gui/src/components/Transcript.tsx
+++ b/surfaces/gui/src/components/Transcript.tsx
@@ -6,6 +6,28 @@ import { Markdown } from "./Markdown";
import { ConnectorMessageCard } from "./ConnectorMessageCard";
import { Icon } from "./Icon";
+// Long user pastes swallow the transcript (owner ask 2026-07-30): clamp past a generous
+// threshold with a more…/less… toggle. Normal typed messages never see the control; the
+// full text still drives copy (BubbleMeta) and is what the model received.
+const USER_CLAMP_CHARS = 1200;
+
+function ClampedUserText({ text }: { text: string }) {
+ const [open, setOpen] = useState(false);
+ if (text.length <= USER_CLAMP_CHARS) return <>{text}>;
+ return (
+ <>
+ {open ? text : text.slice(0, USER_CLAMP_CHARS).trimEnd() + "…"}
+
+ >
+ );
+}
+
// Hover affordances for a message bubble (FB-005): copy the raw text + the message's time.
// Lives in a ZERO-HEIGHT strip under the bubble (absolute, inside the transcript's 20px gap)
// so revealing it on group-hover never shifts the layout. `ts` is unix seconds — canonical
@@ -398,7 +420,7 @@ export function Transcript({ items, running, streamingText, onRetry }: Props) {
)}
)}
- {item.text}
+