diff --git a/ui/src/ui/chat/chat-responsive.browser.test.ts b/ui/src/ui/chat/chat-responsive.browser.test.ts index 01783dc105b..734e19af2f1 100644 --- a/ui/src/ui/chat/chat-responsive.browser.test.ts +++ b/ui/src/ui/chat/chat-responsive.browser.test.ts @@ -68,6 +68,19 @@ function iconSvg() { return ``; } +function chatBubbleActionsHtml() { + return ` +
+ + +
+ `; +} + function chatControlsHtml(opts: { agent?: boolean } = {}) { const showAgent = opts.agent !== false; return ` @@ -168,6 +181,7 @@ function chatHtml(opts: { sideResult?: boolean; singleAgent?: boolean } = {}) {
A
+ ${chatBubbleActionsHtml()}

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

Done.

+
+
+
+
+
+ `, + ); + 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`