Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion familiar-workspace/static/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
<p> 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 <code>; 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 <pre> 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 <code> 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
Expand All @@ -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;
Expand Down
127 changes: 127 additions & 0 deletions tests/e2e/flows/chat-overflow.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
Loading