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} +