diff --git a/ui/src/components/conversation.rs b/ui/src/components/conversation.rs
index bdae7bb1..d54ffe34 100644
--- a/ui/src/components/conversation.rs
+++ b/ui/src/components/conversation.rs
@@ -1808,14 +1808,16 @@ fn MessageGroupComponent(
// Message body, wrapped in a padding container so the
// "(edited)" indicator can sit inline at the trailing
// edge of the body text rather than as a separate
- // flex-column row. `break-words` ensures long URLs and
- // unbreakable tokens wrap instead of clipping (the
- // bubble has `overflow-hidden` to keep the reply strip
- // contained).
+ // flex-column row. `[overflow-wrap:anywhere]` ensures
+ // long URLs and unbreakable tokens wrap instead of
+ // forcing the bubble past `max-w-prose`. `anywhere` is
+ // stricter than `break-word`: it also lowers the
+ // element's min-content so flex/grid parents can shrink
+ // the bubble to fit.
div {
class: "px-3 py-2 min-w-0",
div {
- class: "prose prose-sm dark:prose-invert max-w-none break-words",
+ class: "prose prose-sm dark:prose-invert max-w-none [overflow-wrap:anywhere]",
dangerous_inner_html: "{msg.content_html}"
}
if msg.edited {
diff --git a/ui/src/example_data.rs b/ui/src/example_data.rs
index 41fb5a01..e8552e98 100644
--- a/ui/src/example_data.rs
+++ b/ui/src/example_data.rs
@@ -355,6 +355,25 @@ fn add_example_messages(
messages.messages.push(reply_msg);
}
+ // Add a message containing an unbreakable long URL so the bubble width
+ // regression for #212 is exercised by Playwright.
+ {
+ let (long_url_author_id, long_url_signing_key) = authors[0];
+ current_time_ms += 30_000;
+ let long_url_msg = AuthorizedMessageV1::new(
+ MessageV1 {
+ room_owner: *owner_id,
+ author: long_url_author_id,
+ time: get_time_from_millis(current_time_ms),
+ content: RoomMessageBody::public(
+ "https://example.com/longlongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpath".to_string(),
+ ),
+ },
+ long_url_signing_key,
+ );
+ messages.messages.push(long_url_msg);
+ }
+
// Add reactions to messages from OTHER members (not owner)
// Rule: One reaction per user per message
// In "Your Private Room" the owner IS self, so this shows self reacting to others
diff --git a/ui/tests/message-layout.spec.ts b/ui/tests/message-layout.spec.ts
index 716fa99d..8bf73cf9 100644
--- a/ui/tests/message-layout.spec.ts
+++ b/ui/tests/message-layout.spec.ts
@@ -298,3 +298,67 @@ test.describe("Reply strip keyboard accessibility (#210)", () => {
.toBeGreaterThan(0);
});
});
+
+// #212: a message containing an unbreakable long string (e.g. a long URL)
+// must wrap inside the bubble without overflowing. `overflow-wrap: break-word`
+// (the `break-words` utility) inserts soft breaks when content would
+// otherwise overflow, but does NOT lower min-content sizing — so an ancestor
+// `min-w-0` flex parent still gets stretched by the long token. Switching to
+// `overflow-wrap: anywhere` also lowers min-content, which is what lets the
+// bubble actually shrink to fit.
+test.describe("Long unbreakable content (#212)", () => {
+ test.use({ viewport: { width: 1024, height: 900 } });
+
+ test("bubble with long URL wraps and does not cause horizontal overflow", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await waitForApp(page);
+ await selectRoom(page, "Your Private Room");
+
+ const longTokenBubble = page
+ .locator(".max-w-prose")
+ .filter({ hasText: "longlongurlpath" })
+ .first();
+ await expect(longTokenBubble).toBeVisible({ timeout: 10_000 });
+ await longTokenBubble.scrollIntoViewIfNeeded();
+
+ // Core assertion: the inner prose body must not overflow its own box —
+ // i.e. the long URL must actually wrap. If `overflow-wrap` fails to
+ // apply, the element's min-content exceeds the parent width and
+ // scrollWidth > clientWidth.
+ const proseOverflow = await longTokenBubble.evaluate((el) => {
+ const prose = el.querySelector(".max-w-none") as HTMLElement | null;
+ if (!prose) return { ok: false, reason: "no prose div" };
+ const a = prose.querySelector("a") as HTMLElement | null;
+ const aRect = a?.getBoundingClientRect();
+ const pRect = prose.getBoundingClientRect();
+ return {
+ ok: true,
+ proseScroll: prose.scrollWidth,
+ proseClient: prose.clientWidth,
+ aWidth: aRect?.width ?? 0,
+ proseWidth: pRect.width,
+ };
+ });
+ expect(proseOverflow.ok).toBe(true);
+ // The prose content must not overflow its own container.
+ expect(proseOverflow.proseScroll).toBeLessThanOrEqual(
+ proseOverflow.proseClient + 1
+ );
+ // And the rendered must fit inside the prose box (i.e. the URL
+ // wrapped rather than forcing the link to be wider than its parent).
+ expect(proseOverflow.aWidth).toBeLessThanOrEqual(
+ proseOverflow.proseWidth + 1
+ );
+
+ // Defense in depth: no horizontal overflow on the document. The
+ // original regression screenshot showed the long-URL bubble pushing
+ // the chat column wider than its sibling bubbles.
+ const docOverflow = await page.evaluate(() => ({
+ scroll: document.documentElement.scrollWidth,
+ client: document.documentElement.clientWidth,
+ }));
+ expect(docOverflow.scroll).toBeLessThanOrEqual(docOverflow.client + 1);
+ });
+});