From 1a7b2d901a4cd85004d83ed639a2e7ae097f120c Mon Sep 17 00:00:00 2001 From: sixvolts Date: Sun, 6 Sep 2026 21:03:23 +0000 Subject: [PATCH] fix(chat): stop code blocks and tables forcing the message column wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth attempt, and the first one based on measurement rather than reading CSS. A Playwright probe on icecube rendering a deliberately wide message named the culprit immediately: PRE. scrollW=2864 boxW=2864 CODE.hljs scrollW=2834 boxW=2834 DIV.chat-msg-body boxW=2864 P. boxW=2864 <- prose inherited the width TABLE. boxW=818 <- never the problem The
 was 2864px and dragged .chat-msg-body and both 

siblings with it, which is why PROSE ran off the panel edge. The table was innocent throughout; the three earlier fixes (flex min-width, grid minmax, table styling) all targeted things that were not the cause, which is why none of them helped. max-width:100% on the pre could never work: it resolves against .chat-msg-body, which has no definite width of its own — it is a block in a flex column being stretched BY this very child. Circular. The fix breaks that cycle: width:0 keeps the element's intrinsic size out of the parent's max-content calculation, min-width:100% expands it back to the parent's resolved width, and overflow-x:auto scrolls the long line inside its own box. Applied to both

 and  — the table needed it too once
measured (bubble client=408 vs content=818). The inner  gets no width at
all; width:max-content there re-introduced exactly the 2834px the pre had just
given up.

Verified by re-running the probe after each change: .chat-msg-body and both 

went 2864 -> 818 -> 408, matching the bubble exactly, and only CODE still "overflows" — inside its own scroll container, which is the design. Ships with the probe as tests/e2e/flows/chat-overflow.spec.ts so this cannot regress silently again. It renders a wide table, a 300-char unbroken URL and a 400-char code line, then asserts nothing in the panel has scrollWidth > clientWidth, allowing TABLE/PRE/CODE to scroll within themselves. On failure it prints every offender with its widths, so the next person gets the measurement instead of a guess. --- familiar-workspace/static/app.css | 26 +++++- tests/e2e/flows/chat-overflow.spec.ts | 127 ++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/flows/chat-overflow.spec.ts diff --git a/familiar-workspace/static/app.css b/familiar-workspace/static/app.css index 289fdaf..317c7f4 100644 --- a/familiar-workspace/static/app.css +++ b/familiar-workspace/static/app.css @@ -4105,9 +4105,27 @@ code { inside their own box rather than stretching the message column and taking the whole conversation sideways with it. */ .chat-msg-body pre { + /* width:0 makes this contribute nothing to the parent's max-content size, + so .chat-msg-body is sized by its other children instead of by the + longest code line; min-width:100% then expands it back to whatever the + parent resolved to. max-width:100% alone cannot work here — it resolves + against a parent that has no definite width and is being stretched by + this element. Measured: PRE was 2864px, dragging .chat-msg-body and both +

siblings to 2864px with it. */ + width: 0; + min-width: 100%; max-width: 100%; overflow-x: auto; } +/* highlight.js sets its own display/padding on the inner ; keep it from + re-establishing the wide intrinsic size the pre just gave up. */ +.chat-msg-body pre code { + display: block; + /* No width here. The

 above is the scroll container and already has
+       width:0/min-width:100% to keep its intrinsic size out of the parent's
+       sizing; giving the inner  width:max-content re-introduced exactly
+       the 2834px the pre had just given up. */
+}
 /* Markdown tables in chat. The model emits plain | pipe | tables and they
    arrived completely unstyled: no header rule, no cell padding, no column
    gap — adjacent cells ran together ("Rest of Europe / other" abutting
@@ -4120,7 +4138,13 @@ code {
    to the panel, while still allowing wide ones to scroll. */
 .chat-msg-body table {
     display: block;
-    width: max-content;
+    /* Same circular-width fix as .chat-msg-body pre: width:0 keeps the table's
+       intrinsic size out of the parent's max-content calculation, min-width
+       expands it back to the resolved parent width, and overflow-x scrolls the
+       remainder inside the table's own box. width:max-content here made the
+       table force the bubble wide (measured: bubble client=408, content=818). */
+    width: 0;
+    min-width: 100%;
     max-width: 100%;
     overflow-x: auto;
     border-collapse: collapse;
diff --git a/tests/e2e/flows/chat-overflow.spec.ts b/tests/e2e/flows/chat-overflow.spec.ts
new file mode 100644
index 0000000..31a9bd5
--- /dev/null
+++ b/tests/e2e/flows/chat-overflow.spec.ts
@@ -0,0 +1,127 @@
+// chat-overflow.spec.ts — the chat panel must never scroll horizontally.
+//
+// Three fixes for this shipped on reasoning rather than measurement, and two
+// made it worse (one clipped the text with overflow-x:hidden, removing any way
+// to reach what was cut off). This test measures instead: it seeds a
+// deliberately wide assistant message — a table with long cells, an unbroken
+// 300-char URL, and a 400-char code line — then asserts nothing in the chat
+// panel has scrollWidth > clientWidth.
+//
+// A TABLE or PRE scrolling inside ITSELF is the intended design and is
+// allowed; anything else overflowing is the bug. On failure the assertion
+// message names every offender with its widths, so the culprit is identified
+// rather than guessed.
+
+import { test as base, expect } from "@playwright/test";
+import { start, GatewayStack } from "../fixtures/gateway";
+import { createTestUser, attachSession } from "../fixtures/user";
+
+const test = base.extend<{}, { stack: GatewayStack }>({
+    stack: [
+        async ({}, use) => {
+            const stack = await start({ admin: true });
+            await use(stack);
+            await stack.stop();
+        },
+        { scope: "worker" },
+    ],
+});
+
+const WIDE = [
+    "Here is a wide table:",
+    "",
+    "| Part | Ship | Issue / execution | L1 | Notes |",
+    "|---|---|---|---|---|",
+    "| Pentium P5 | Mar 1993 | Dual-issue in-order, U/V pipes, retires in order, no renaming | 4K I + 4K D | first superscalar x86 |",
+    "| PowerPC 603 / 603e | 1993 | In-order, limited dual-issue, 5-stage pipeline with folding | 32K I + 32K D | low power target |",
+    "| MIPS R4600 / RM5200 | 1997 | In-order dual-issue with a deep write buffer | on-chip unified | embedded focus |",
+    "",
+    "Unbroken token: https://example.com/" + "a".repeat(300),
+    "",
+    "```",
+    "x".repeat(400),
+    "```",
+].join("\n");
+
+test("the chat panel never scrolls horizontally", async ({ stack, browser, request }) => {
+    const user = await createTestUser({ role: "admin" });
+    const authed = { Cookie: user.cookieHeader, "Content-Type": "application/json" };
+
+    const conv = await (
+        await request.post(`${stack.workspaceURL}/console/api/conversations`, {
+            headers: authed,
+            data: { title: "Overflow probe", model: "familiar" },
+        })
+    ).json();
+    await request.post(`${stack.workspaceURL}/console/api/conversations/${conv.id}/messages`, {
+        headers: authed,
+        data: { role: "user", content: "show me a wide table" },
+    });
+    await request.post(`${stack.workspaceURL}/console/api/conversations/${conv.id}/messages`, {
+        headers: authed,
+        data: { role: "assistant", content: WIDE },
+    });
+
+    const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+    await attachSession(ctx, stack.workspaceURL, user);
+    const page = await ctx.newPage();
+    try {
+        await page.goto(stack.workspaceURL);
+        await expect(page.locator("#view-dashboard")).toBeVisible({ timeout: 15_000 });
+        await page.locator(".sidebar-cat-chat").click();
+        await page.evaluate(
+            (id) => window.dispatchEvent(new CustomEvent("familiar:openDoc", { detail: { surface: "chat", id } })),
+            conv.id,
+        );
+
+        const shell = page.locator(".chat-shell", { hasText: "Unbroken token" });
+        await expect(shell).toBeVisible({ timeout: 15_000 });
+        await page.waitForTimeout(600); // let layout settle
+
+        // Diagnostic: name the widest descendants of the bubble outright,
+        // so the offending element is identified rather than inferred.
+        const widest = await page.evaluate(() => {
+            const bubble = document.querySelector(".chat-msg-assistant");
+            if (!bubble) return ["no bubble"];
+            return Array.from(bubble.querySelectorAll("*"))
+                .map((el) => {
+                    const cls = typeof el.className === "string" ? el.className : "";
+                    const w = Math.round(el.getBoundingClientRect().width);
+                    const txt = (el.textContent || "").slice(0, 32).replace(/\s+/g, " ");
+                    return el.tagName + "." + cls + " scrollW=" + el.scrollWidth +
+                           " boxW=" + w + " :: " + txt;
+                })
+                .sort((a, b) => {
+                    const na = Number(a.match(/scrollW=(\d+)/)[1]);
+                    const nb = Number(b.match(/scrollW=(\d+)/)[1]);
+                    return nb - na;
+                })
+                .slice(0, 10);
+        });
+        console.log("WIDEST DESCENDANTS:\n  " + widest.join("\n  "));
+
+        const offenders = await page.evaluate(() => {
+            const out = [];
+            const root = document.querySelector(".chat-messages");
+            if (!root) return ["NO .chat-messages FOUND"];
+            const nodes = Array.from(root.querySelectorAll("*"));
+            for (let el = root; el; el = el.parentElement) nodes.push(el);
+            for (const el of nodes) {
+                if (el.scrollWidth > el.clientWidth + 1) {
+                    const cls = typeof el.className === "string" ? el.className : "";
+                    out.push(el.tagName + "." + cls +
+                        " scroll=" + el.scrollWidth + " client=" + el.clientWidth);
+                }
+            }
+            return out;
+        });
+
+        // TABLE / PRE / the thinking trace scrolling inside themselves is by design.
+        const bad = offenders.filter(
+            (o) => !/^(TABLE|PRE|CODE)\./.test(o) && !/chat-msg-thinking-body/.test(o),
+        );
+        expect(bad, "unexpected horizontal overflow:\n" + offenders.join("\n")).toEqual([]);
+    } finally {
+        await ctx.close();
+    }
+});