The chat shell should stay compact and readable.
const importantLongIdentifier = "control-ui-chat-responsive-regression-fixture-keeps-code-scrollable"; console.log(importantLongIdentifier);
@@ -226,6 +240,41 @@ async function openFixture(
return page;
}
+async function getRect(page: Page, selector: string) {
+ const rect = await page.locator(selector).evaluate((node) => {
+ const bounds = (node as HTMLElement).getBoundingClientRect();
+ return {
+ left: bounds.left,
+ right: bounds.right,
+ top: bounds.top,
+ bottom: bounds.bottom,
+ width: bounds.width,
+ height: bounds.height,
+ };
+ });
+ expectFiniteRect({ x: rect.left, y: rect.top, width: rect.width, height: rect.height });
+ return rect;
+}
+
+async function getTextContentRect(page: Page, selector: string) {
+ const rect = await page.locator(selector).evaluate((node) => {
+ const range = document.createRange();
+ range.selectNodeContents(node);
+ const bounds = range.getBoundingClientRect();
+ range.detach();
+ return {
+ left: bounds.left,
+ right: bounds.right,
+ top: bounds.top,
+ bottom: bounds.bottom,
+ width: bounds.width,
+ height: bounds.height,
+ };
+ });
+ expectFiniteRect({ x: rect.left, y: rect.top, width: rect.width, height: rect.height });
+ return rect;
+}
+
async function openHeaderFixture(width: number, height: number, opts: { hidden?: boolean } = {}) {
const page = await browser.newPage({ viewport: { width, height } });
await page.setContent(
@@ -326,6 +375,42 @@ describeBrowserLayout("chat responsive browser layout", () => {
}
});
+ it.each([
+ [320, 568],
+ [1366, 900],
+ ] as const)(
+ "keeps short assistant text clear of bubble actions at %sx%s",
+ async (width, height) => {
+ const page = await browser.newPage({ viewport: { width, height } });
+ try {
+ await page.setContent(
+ `
+
+
+
+
A
+
+
+ ${chatBubbleActionsHtml()}
+
+
+
+
+
+
+ `,
+ );
+ await page.locator(".chat-bubble").hover();
+
+ const text = await getTextContentRect(page, ".chat-text p");
+ const actions = await getRect(page, ".chat-bubble-actions");
+ expect(text.right).toBeLessThanOrEqual(actions.left - 1);
+ } finally {
+ await page.close();
+ }
+ },
+ );
+
it.each(["dark", "light"] as const)(
"keeps mobile controls inside the viewport with touch targets in %s mode",
async (themeMode) => {
diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts
index cc97276cc4a..d6b7ea3797e 100644
--- a/ui/src/ui/chat/grouped-render.test.ts
+++ b/ui/src/ui/chat/grouped-render.test.ts
@@ -481,6 +481,37 @@ describe("grouped chat rendering", () => {
expect(container.querySelector('[aria-label="Read aloud"]')).toBeNull();
});
+ it("reserves bubble space when assistant message actions render", () => {
+ const container = document.createElement("div");
+ renderAssistantMessage(container, {
+ role: "assistant",
+ content: "Short reply",
+ timestamp: 1000,
+ });
+
+ const assistantBubble = expectElement(
+ container,
+ ".chat-group.assistant .chat-bubble",
+ HTMLElement,
+ );
+ expect(assistantBubble.classList.contains("has-copy")).toBe(true);
+ expect(assistantBubble.querySelector(".chat-bubble-actions")).toBeInstanceOf(HTMLElement);
+
+ renderGroupedMessage(
+ container,
+ {
+ role: "user",
+ content: "Short reply",
+ timestamp: 1001,
+ },
+ "user",
+ );
+
+ const userBubble = expectElement(container, ".chat-group.user .chat-bubble", HTMLElement);
+ expect(userBubble.classList.contains("has-copy")).toBe(false);
+ expect(userBubble.querySelector(".chat-bubble-actions")).toBeNull();
+ });
+
it("positions delete confirm by message side", () => {
const container = document.createElement("div");
clearDeleteConfirmSkip();
diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts
index 25d9a326ea9..b98689d4879 100644
--- a/ui/src/ui/chat/grouped-render.ts
+++ b/ui/src/ui/chat/grouped-render.ts
@@ -1436,6 +1436,7 @@ function renderGroupedMessage(
const markdown = markdownBase;
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
const canExpand = role === "assistant" && Boolean(onOpenSidebar && markdown?.trim());
+ const hasActions = canCopyMarkdown || canExpand;
// Detect pure-JSON messages and render as collapsible block
const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null;
@@ -1444,6 +1445,7 @@ function renderGroupedMessage(
const bubbleClasses = [
"chat-bubble",
isToolMessage ? "chat-bubble--tool-shell" : "",
+ hasActions ? "has-copy" : "",
opts.isStreaming ? "streaming" : "",
"fade-in",
]
@@ -1491,7 +1493,6 @@ function renderGroupedMessage(
: "Tool output";
const toolMessageIcon = singleToolDisplay ? icons[singleToolDisplay.icon] : icons.zap;
- const hasActions = canCopyMarkdown || canExpand;
const duplicateCount = Math.max(1, Math.floor(opts.duplicateCount ?? 1));
return html`