mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 02:45:38 -06:00
fix(ui): keep expanded message actions aligned with visible text (#117983)
Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
committed by
GitHub
parent
aa02128aca
commit
aeaaaad4d0
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user