fix(ui): keep expanded message actions aligned with visible text (#117983)

Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
Peter Steinberger
2026-08-02 05:49:56 -07:00
committed by GitHub
parent aa02128aca
commit aeaaaad4d0
5 changed files with 270 additions and 21 deletions
@@ -362,6 +362,21 @@ describeControlUiE2e("Control UI chat message actions", () => {
const expandableBubble = page.locator(
'.chat-bubble[data-entry-id="assistant-truncated-proof"]',
);
const expandableGroup = expandableBubble.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' chat-group ')]",
);
await expandableGroup.hover();
await expandableGroup.getByRole("button", { name: "Copy as markdown" }).click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(truncatedPreview);
await expandableGroup.getByRole("button", { name: "Reply to message" }).click();
const expandableReplyPreview = page.locator(".chat-reply-preview");
await expect
.poll(() => expandableReplyPreview.locator(".chat-reply-preview__text").textContent())
.toBe(truncatedPreview);
await expandableReplyPreview.getByRole("button", { name: "Cancel reply" }).click();
await expandableBubble.getByRole("button", { name: "Show more" }).click();
const fullMessageRequest = await gateway.waitForRequest("chat.message.get");
expect(fullMessageRequest.params).toMatchObject({
@@ -372,6 +387,28 @@ describeControlUiE2e("Control UI chat message actions", () => {
await expandableBubble.getByText(fullAssistantContent, { exact: true }).waitFor({
state: "visible",
});
await expandableGroup.hover();
await expandableGroup.getByRole("button", { name: "Copy as markdown" }).click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(fullAssistantContent);
await expandableGroup.getByRole("button", { name: "Reply to message" }).click();
await expect
.poll(() => expandableReplyPreview.locator(".chat-reply-preview__text").textContent())
.toBe(fullAssistantContent);
await expandableReplyPreview.getByRole("button", { name: "Cancel reply" }).click();
await expandableBubble.click({ button: "right" });
await menu.getByRole("menuitem", { name: "Copy as markdown" }).click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(fullAssistantContent);
await expandableBubble.click({ button: "right" });
await menu.getByRole("menuitem", { name: "Reply to message" }).click();
await expect
.poll(() => expandableReplyPreview.locator(".chat-reply-preview__text").textContent())
.toBe(fullAssistantContent);
await expandableReplyPreview.getByRole("button", { name: "Cancel reply" }).click();
await expandableBubble.getByRole("button", { name: "Show less" }).click();
await expect
@@ -383,6 +420,16 @@ describeControlUiE2e("Control UI chat message actions", () => {
.join("\n"),
)
.toBe(truncatedPreview);
await expandableGroup.hover();
await expandableGroup.getByRole("button", { name: "Copy as markdown" }).click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(truncatedPreview);
await expandableGroup.getByRole("button", { name: "Reply to message" }).click();
await expect
.poll(() => expandableReplyPreview.locator(".chat-reply-preview__text").textContent())
.toBe(truncatedPreview);
await expandableReplyPreview.getByRole("button", { name: "Cancel reply" }).click();
await expandableBubble.getByRole("button", { name: "Show more" }).click();
await expandableBubble.getByText(fullAssistantContent, { exact: true }).waitFor({
state: "visible",
@@ -173,6 +173,7 @@ export function renderGroupedMessage(
isUserMessageExpanded?: (messageId: string) => boolean;
onToggleUserMessageExpanded?: (messageId: string) => void;
assistantMessageDisclosure?: AssistantMessageDisclosure;
actionMarkdown?: string;
isToolExpanded?: (toolCardId: string) => boolean;
onToggleToolExpanded?: (toolCardId: string) => void;
onRequestUpdate?: () => void;
@@ -223,6 +224,7 @@ export function renderGroupedMessage(
const hasPairingQrExpiryNotices = pairingQrExpiryNotices.length > 0;
const extractedText = resolveNormalizedMessageMarkdown(normalizedMessage);
const actionText = opts.actionMarkdown ?? extractedText;
const assistantAttachments = normalizedMessage.content.filter(
(item): item is AttachmentItem => item.type === "attachment",
);
@@ -346,7 +348,7 @@ export function renderGroupedMessage(
class="${bubbleClasses}"
data-message-id=${messageKey}
data-entry-id=${opts.entryId || nothing}
data-message-text=${extractedText || nothing}
data-message-text=${actionText || nothing}
>
${renderReplyPill(normalizedMessage.replyTarget)}
${renderInlineToolCards(toolCards, {
@@ -381,7 +383,7 @@ export function renderGroupedMessage(
class="${bubbleClasses}"
data-message-id=${messageKey}
data-entry-id=${opts.entryId || nothing}
data-message-text=${extractedText || nothing}
data-message-text=${actionText || nothing}
>
${renderReplyPill(normalizedMessage.replyTarget)}
${isStandaloneToolMessage
@@ -119,7 +119,7 @@ function buildGroupedMessageRenderOptions(
const expansion = opts.getAssistantMessageExpansion?.(messageId);
assistantMessageDisclosure = {
expanded: expansion?.status === "loaded" && expansion.expanded,
...(expansion?.status === "loaded" ? { markdown: expansion.markdown } : {}),
...(expansion?.status === "loaded" ? { markdown: actionDetails.markdown } : {}),
loading: expansion?.status === "loading",
error: expansion?.status === "error",
onToggle: () => opts.onToggleAssistantMessageExpanded?.(messageId),
@@ -146,6 +146,7 @@ function buildGroupedMessageRenderOptions(
isUserMessageExpanded: opts.isUserMessageExpanded,
onToggleUserMessageExpanded: opts.onToggleUserMessageExpanded,
assistantMessageDisclosure,
actionMarkdown: actionDetails?.markdown,
isToolExpanded: opts.isToolExpanded,
onToggleToolExpanded: opts.onToggleToolExpanded,
onRequestUpdate: opts.onRequestUpdate,
@@ -361,6 +362,7 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
message: item.message,
messageId: item.key,
canFetchFullMessage: Boolean(opts.loadFullAssistantMessage && opts.sessionKey),
getAssistantMessageExpansion: opts.getAssistantMessageExpansion,
onReply: opts.onReply,
senderLabel: who,
}),
@@ -11,7 +11,7 @@ import { normalizeMessage } from "../../../lib/chat/message-normalizer.ts";
import { normalizeRoleForGrouping } from "../../../lib/chat/message-normalizer.ts";
import { stripThinkingTags } from "../../../lib/strip-thinking-tags.ts";
import { detectTextDirection } from "../../../lib/text-direction.ts";
import { persistedMessageEntryId } from "../chat-thread.ts";
import { persistedMessageEntryId, type AssistantMessageExpansionState } from "../chat-thread.ts";
import { renderDeleteButton } from "./chat-message-confirmation.ts";
export type MessageReplyTarget = {
@@ -88,21 +88,12 @@ export function resolveMessageActionDetails(params: {
message: unknown;
messageId: string;
canFetchFullMessage?: boolean;
getAssistantMessageExpansion?: (messageId: string) => AssistantMessageExpansionState | undefined;
onReply?: (target: MessageReplyTarget) => void;
senderLabel: string;
}): MessageActionDetails | null {
const { message, messageId: renderMessageId, canFetchFullMessage, onReply, senderLabel } = params;
const record = message as Record<string, unknown>;
const normalizedMessage = normalizeMessage(message);
const normalizedMarkdown = resolveNormalizedMessageMarkdown(normalizedMessage);
const role = normalizeRoleForGrouping(normalizedMessage.role);
const visibleMarkdown =
role === "assistant" ? stripThinkingTags(normalizedMarkdown).trim() : normalizedMarkdown.trim();
const markdown = role === "assistant" ? visibleMarkdown : undefined;
const replyText = onReply ? truncateUtf16Safe(visibleMarkdown, 500) : "";
if (!markdown && !replyText) {
return null;
}
const transcriptMeta =
record["__openclaw"] &&
typeof record["__openclaw"] === "object" &&
@@ -115,9 +106,35 @@ export function resolveMessageActionDetails(params: {
: typeof record.messageId === "string"
? record.messageId
: undefined;
const normalizedMessage = normalizeMessage(message);
const normalizedMarkdown = resolveNormalizedMessageMarkdown(normalizedMessage);
const role = normalizeRoleForGrouping(normalizedMessage.role);
const previewMarkdown =
role === "assistant" ? stripThinkingTags(normalizedMarkdown).trim() : normalizedMarkdown.trim();
// Loaded text must not erase the preview's truncation fact or collapse its disclosure.
const shouldFetchFullMessage = Boolean(
canFetchFullMessage &&
messageId &&
!record.openclawMessageToolMirror &&
(transcriptMeta?.truncated === true ||
(role === "assistant" && previewMarkdown.includes("\n...(truncated)..."))),
);
const expansion =
role === "assistant" && shouldFetchFullMessage && messageId
? params.getAssistantMessageExpansion?.(messageId)
: undefined;
const visibleMarkdown =
expansion?.status === "loaded" && expansion.expanded
? stripThinkingTags(expansion.markdown).trim()
: previewMarkdown;
const markdown = role === "assistant" ? visibleMarkdown : undefined;
const replyText = onReply ? truncateUtf16Safe(visibleMarkdown, 500) : "";
if (!markdown && !replyText && !(role === "assistant" && shouldFetchFullMessage)) {
return null;
}
const sourceMessageId = persistedMessageEntryId(message);
return {
...(markdown ? { markdown } : {}),
...(markdown === undefined ? {} : { markdown }),
messageId,
...(replyText
? {
@@ -129,12 +146,7 @@ export function resolveMessageActionDetails(params: {
},
}
: {}),
shouldFetchFullMessage: Boolean(
canFetchFullMessage &&
messageId &&
!record.openclawMessageToolMirror &&
(transcriptMeta?.truncated === true || markdown?.includes("\n...(truncated)...")),
),
shouldFetchFullMessage,
};
}
@@ -5174,6 +5174,192 @@ describe("grouped chat rendering", () => {
expect(requireFirstMockArg(onOpenSidebar, "sidebar open").kind).toBe("markdown");
});
function renderAssistantDisclosureActionFixture(
expanded: boolean,
options: Partial<RenderMessageGroupOptions> = {},
) {
const container = document.createElement("div");
const preview = "Assistant preview\n...(truncated)...";
const fullMessage = "Complete assistant message beyond the transcript preview.";
renderAssistantMessage(
container,
{
role: "assistant",
content: [{ type: "text", text: preview }],
__openclaw: { id: "assistant-disclosure-actions", seq: 1 },
},
{
sessionKey: "agent:main:main",
loadFullAssistantMessage: async () => null,
getAssistantMessageExpansion: () => ({
status: "loaded",
expanded,
markdown: fullMessage,
revision: 1,
}),
onToggleAssistantMessageExpanded: vi.fn(),
...options,
},
);
return { container, fullMessage, preview };
}
it.each([
{ expanded: false, label: "collapsed" },
{ expanded: true, label: "expanded" },
])("copies the currently visible $label assistant message", async ({ expanded }) => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } } as unknown as Navigator);
const { container, fullMessage, preview } = renderAssistantDisclosureActionFixture(expanded);
const expectedMessage = expanded ? fullMessage : preview;
expect(container.querySelector(".chat-message-disclosure__content")?.textContent).toContain(
expectedMessage,
);
container
.querySelector<HTMLButtonElement>(".chat-group-footer-actions .chat-copy-btn")
?.click();
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith(expectedMessage));
});
it.each([
{ expanded: false, label: "collapsed" },
{ expanded: true, label: "expanded" },
])("replies to the currently visible $label assistant message", ({ expanded }) => {
const onReply = vi.fn();
const { container, fullMessage, preview } = renderAssistantDisclosureActionFixture(expanded, {
onReply,
});
const expectedMessage = expanded ? fullMessage : preview;
container
.querySelector<HTMLButtonElement>(
'.chat-group-footer-actions [aria-label="Reply to message"]',
)
?.click();
expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ text: expectedMessage }));
expect(container.querySelector<HTMLElement>(".chat-bubble")?.dataset.messageText).toBe(
expectedMessage,
);
});
it.each(["loading", "error"] as const)(
"keeps the transcript preview in %s assistant-message actions",
(status) => {
const onReply = vi.fn();
const { container, preview } = renderAssistantDisclosureActionFixture(false, {
onReply,
getAssistantMessageExpansion: () => ({ status, revision: 1 }),
});
container
.querySelector<HTMLButtonElement>(
'.chat-group-footer-actions [aria-label="Reply to message"]',
)
?.click();
expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ text: preview }));
expect(container.querySelector<HTMLElement>(".chat-bubble")?.dataset.messageText).toBe(
preview,
);
},
);
it("keeps expanded assistant thinking private while bounding reply context", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } } as unknown as Navigator);
const onReply = vi.fn();
const visibleMessage = `${"a".repeat(499)}😀 full expanded answer`;
const { container } = renderAssistantDisclosureActionFixture(true, {
onReply,
getAssistantMessageExpansion: () => ({
status: "loaded",
expanded: true,
markdown: `<thinking>private expanded reasoning</thinking>${visibleMessage}`,
revision: 1,
}),
});
container
.querySelector<HTMLButtonElement>(".chat-group-footer-actions .chat-copy-btn")
?.click();
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith(visibleMessage));
container
.querySelector<HTMLButtonElement>(
'.chat-group-footer-actions [aria-label="Reply to message"]',
)
?.click();
expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ text: "a".repeat(499) }));
expect(container.querySelector<HTMLElement>(".chat-bubble")?.dataset.messageText).toBe(
visibleMessage,
);
});
it("keeps hidden-only expanded assistant messages recoverable without exposing stale text", () => {
const onReply = vi.fn();
const onToggleAssistantMessageExpanded = vi.fn();
const privateThinking = "private expanded reasoning only";
const { container, preview } = renderAssistantDisclosureActionFixture(true, {
onReply,
onToggleAssistantMessageExpanded,
getAssistantMessageExpansion: () => ({
status: "loaded",
expanded: true,
markdown: `<thinking>${privateThinking}</thinking>`,
revision: 1,
}),
});
const disclosure = expectElement(container, ".chat-message-disclosure", HTMLDivElement);
expect(disclosure.classList.contains("is-expanded")).toBe(true);
expect(disclosure.querySelector(".chat-message-disclosure__content")?.textContent?.trim()).toBe(
"",
);
expect(disclosure.textContent).not.toContain(privateThinking);
expect(disclosure.textContent).not.toContain(preview);
expect(container.querySelector(".chat-group-footer-actions .chat-copy-btn")).toBeNull();
expect(container.querySelector('[aria-label="Reply to message"]')).toBeNull();
expect(
container.querySelector<HTMLElement>(".chat-bubble")?.hasAttribute("data-message-text"),
).toBe(false);
const toggle = expectElement(disclosure, ".chat-message-disclosure__toggle", HTMLButtonElement);
expect(toggle.textContent?.trim()).toBe("Show less");
toggle.click();
expect(onToggleAssistantMessageExpanded).toHaveBeenCalledWith("assistant-disclosure-actions");
renderAssistantMessage(
container,
{
role: "assistant",
content: [{ type: "text", text: preview }],
__openclaw: { id: "assistant-disclosure-actions", seq: 1 },
},
{
sessionKey: "agent:main:main",
loadFullAssistantMessage: async () => null,
getAssistantMessageExpansion: () => ({
status: "loaded",
expanded: false,
markdown: `<thinking>${privateThinking}</thinking>`,
revision: 2,
}),
onToggleAssistantMessageExpanded,
onReply,
},
);
expect(container.querySelector(".chat-message-disclosure__content")?.textContent).toContain(
preview,
);
expect(container.querySelector(".chat-message-disclosure__toggle")?.textContent?.trim()).toBe(
"Show more",
);
expect(container.querySelector<HTMLElement>(".chat-bubble")?.dataset.messageText).toBe(preview);
});
it.each([
{
label: "marker",