fix(ui): prevent chat actions overlapping replies

This commit is contained in:
JARVIS-Glasses
2026-05-13 03:21:36 +02:00
committed by Peter Steinberger
parent 4e34c1aa47
commit 3b7181a38b
3 changed files with 118 additions and 1 deletions

View File

@@ -68,6 +68,19 @@ function iconSvg() {
return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg>`;
}
function chatBubbleActionsHtml() {
return `
<div class="chat-bubble-actions">
<button class="btn btn--xs chat-expand-btn" type="button" aria-label="Open in canvas">
<span class="chat-expand-btn__icon" aria-hidden="true">${iconSvg()}</span>
</button>
<button class="btn btn--xs chat-copy-btn" type="button" aria-label="Copy as markdown">
<span class="chat-copy-btn__icon" aria-hidden="true">${iconSvg()}</span>
</button>
</div>
`;
}
function chatControlsHtml(opts: { agent?: boolean } = {}) {
const showAgent = opts.agent !== false;
return `
@@ -168,6 +181,7 @@ function chatHtml(opts: { sideResult?: boolean; singleAgent?: boolean } = {}) {
<div class="chat-avatar assistant">A</div>
<div class="chat-group-messages">
<div class="chat-bubble has-copy">
${chatBubbleActionsHtml()}
<div class="chat-text">
<p>The chat shell should stay compact and readable.</p>
<pre><code>const importantLongIdentifier = "control-ui-chat-responsive-regression-fixture-keeps-code-scrollable"; console.log(importantLongIdentifier);</code></pre>
@@ -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(
`<!doctype html><html><head><style>${readUiCss()}</style></head><body>
<div class="chat-thread" role="log">
<div class="chat-thread-inner">
<div class="chat-group assistant">
<div class="chat-avatar assistant">A</div>
<div class="chat-group-messages">
<div class="chat-bubble has-copy">
${chatBubbleActionsHtml()}
<div class="chat-text"><p>Done.</p></div>
</div>
</div>
</div>
</div>
</div>
</body></html>`,
);
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) => {

View File

@@ -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();

View File

@@ -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`