diff --git a/ui/src/e2e/chat-message-actions.e2e.test.ts b/ui/src/e2e/chat-message-actions.e2e.test.ts index 76b9f746645b..90d26d247e1d 100644 --- a/ui/src/e2e/chat-message-actions.e2e.test.ts +++ b/ui/src/e2e/chat-message-actions.e2e.test.ts @@ -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", diff --git a/ui/src/pages/chat/components/chat-message-bubble.ts b/ui/src/pages/chat/components/chat-message-bubble.ts index b7021fc1d11d..7a07a9ad3a43 100644 --- a/ui/src/pages/chat/components/chat-message-bubble.ts +++ b/ui/src/pages/chat/components/chat-message-bubble.ts @@ -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 diff --git a/ui/src/pages/chat/components/chat-message-group.ts b/ui/src/pages/chat/components/chat-message-group.ts index 9bf994581f1b..9088f521048e 100644 --- a/ui/src/pages/chat/components/chat-message-group.ts +++ b/ui/src/pages/chat/components/chat-message-group.ts @@ -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, }), diff --git a/ui/src/pages/chat/components/chat-message-markdown.ts b/ui/src/pages/chat/components/chat-message-markdown.ts index cead6e9805e1..acc8a6f899d9 100644 --- a/ui/src/pages/chat/components/chat-message-markdown.ts +++ b/ui/src/pages/chat/components/chat-message-markdown.ts @@ -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; - 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, }; } diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index a528f4049195..c89369fdebaa 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -5174,6 +5174,192 @@ describe("grouped chat rendering", () => { expect(requireFirstMockArg(onOpenSidebar, "sidebar open").kind).toBe("markdown"); }); + function renderAssistantDisclosureActionFixture( + expanded: boolean, + options: Partial = {}, + ) { + 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(".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( + '.chat-group-footer-actions [aria-label="Reply to message"]', + ) + ?.click(); + + expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ text: expectedMessage })); + expect(container.querySelector(".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( + '.chat-group-footer-actions [aria-label="Reply to message"]', + ) + ?.click(); + expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ text: preview })); + expect(container.querySelector(".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: `private expanded reasoning${visibleMessage}`, + revision: 1, + }), + }); + + container + .querySelector(".chat-group-footer-actions .chat-copy-btn") + ?.click(); + await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith(visibleMessage)); + + container + .querySelector( + '.chat-group-footer-actions [aria-label="Reply to message"]', + ) + ?.click(); + expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ text: "a".repeat(499) })); + expect(container.querySelector(".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: `${privateThinking}`, + 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(".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: `${privateThinking}`, + 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(".chat-bubble")?.dataset.messageText).toBe(preview); + }); + it.each([ { label: "marker",