mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
fix(ui): prevent chat actions overlapping replies
This commit is contained in:
committed by
Peter Steinberger
parent
4e34c1aa47
commit
3b7181a38b
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user