mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): always show full assistant responses (#122207)
This commit is contained in:
committed by
GitHub
parent
6a4a546593
commit
ce466fccb0
@@ -17,6 +17,137 @@ const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM
|
||||
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
|
||||
const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/chat-message-actions");
|
||||
const transportPreviewLimit = 8_000;
|
||||
|
||||
const realisticFullAssistantContent = `# Refactor complete: one transcript-loading path
|
||||
|
||||
I finished the Control UI transcript refactor. Ordinary messages render exactly as before, but a long assistant response no longer leaves the operator behind a disclosure control when the complete Markdown is available.
|
||||
|
||||
## What was wrong
|
||||
|
||||
The transcript list already knew the session key and stable message id, while each message group separately derived enough state to render Markdown, copy text, and build reply context. When history contained a transport-limited preview, those consumers could disagree: the bubble showed the preview, Copy used cached text, and Reply rebuilt text from the original transcript entry.
|
||||
|
||||
The underlying problem was ownership. Full-message loading already belonged to the thread, but visibility was controlled farther down the render tree. That meant the UI could possess the complete response and still choose to show only the preview until someone clicked Show more.
|
||||
|
||||
## Files reviewed
|
||||
|
||||
### ui/src/pages/chat/chat-thread.ts
|
||||
|
||||
The page owns the session-scoped map of assistant message expansions. Each entry records a revision and a loading, loaded, or failed result. The revision participates in row memoization, so completing a request invalidates the affected row without rebuilding the entire transcript.
|
||||
|
||||
The cache key combines the active session and message id. Switching sessions therefore cannot leak loaded text into another conversation, even when deterministic fixtures reuse a local id.
|
||||
|
||||
~~~ts
|
||||
type AssistantMessageExpansionState =
|
||||
| { status: "loading"; revision: number }
|
||||
| { status: "error"; revision: number }
|
||||
| { status: "loaded"; expanded: boolean; markdown: string; revision: number };
|
||||
~~~
|
||||
|
||||
This state and the existing chat.message.get request remain unchanged. The UI adjustment only removes the separate assistant disclosure decision after the loaded Markdown is present.
|
||||
|
||||
### ui/src/pages/chat/components/chat-message-group.ts
|
||||
|
||||
The group is the shared boundary for bubble content and message actions. It already checks the gateway suffix and transcriptMeta.truncated, looks up the stable message id, and receives the existing full-message callback from the thread.
|
||||
|
||||
The group now starts that existing callback when it first sees an eligible entry instead of waiting for a button click. It does not introduce another cache, request type, retry timer, or transport flag. A loading or failed entry is left alone, which prevents render-driven request loops.
|
||||
|
||||
~~~ts
|
||||
for (const details of messageActionDetails) {
|
||||
if (
|
||||
details?.shouldFetchFullMessage &&
|
||||
details.messageId &&
|
||||
!getAssistantMessageExpansion(details.messageId)
|
||||
) {
|
||||
onToggleAssistantMessageExpanded(details.messageId);
|
||||
}
|
||||
}
|
||||
~~~
|
||||
|
||||
Once the existing loader resolves, the same selected Markdown flows to the bubble, inline Copy action, Reply action, and context menu. No consumer has to infer whether a loaded response should still look collapsed.
|
||||
|
||||
### ui/src/pages/chat/components/chat-message-markdown.ts
|
||||
|
||||
Assistant Markdown now renders directly. If the existing expansion record contains loaded text, that text is selected regardless of the old expanded boolean. Otherwise the transcript text is shown exactly as received.
|
||||
|
||||
Loaded text uses document rendering mode so the chat renderer does not apply its regular large-message preview limit to a response that was explicitly recovered in full. User-authored messages keep their separate local disclosure behavior; this change only removes the assistant control.
|
||||
|
||||
~~~ts
|
||||
const markdown = disclosure?.expanded
|
||||
? disclosure.markdown ?? previewMarkdown
|
||||
: previewMarkdown;
|
||||
|
||||
return renderMarkdownText(markdown, isStreaming, renderOptions);
|
||||
~~~
|
||||
|
||||
There is no assistant footer, loading label, Show more button, Show less button, or inline failure banner. During the existing request, the transport text remains readable. When complete text arrives, the same bubble grows naturally to fit it.
|
||||
|
||||
## Request lifecycle
|
||||
|
||||
1. History supplies the transport representation of the assistant message.
|
||||
2. The existing truncation check determines whether a stored full copy can be requested.
|
||||
3. The group invokes the already-shipped full-message callback once.
|
||||
4. The thread sends chat.message.get with the stable session and message identifiers.
|
||||
5. A successful result replaces the visible Markdown in the existing bubble.
|
||||
6. Copy and Reply immediately use the same complete Markdown.
|
||||
|
||||
The request shape remains the one already supported by the gateway:
|
||||
|
||||
~~~json
|
||||
{
|
||||
"sessionKey": "main",
|
||||
"messageId": "assistant-refactor-report",
|
||||
"maxChars": 500000
|
||||
}
|
||||
~~~
|
||||
|
||||
Those names are deterministic fixture values. This scenario contains no production session identifiers, account data, credentials, channel addresses, provider keys, or access tokens.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
If no complete stored message is available, the UI keeps the transcript text. It does not replace useful content with an error card or leave a dead control on screen. The existing failed state prevents the render pass from immediately issuing the same request again.
|
||||
|
||||
This matters during reconnects and partial history imports: the operator still sees the information that actually reached the browser. The UI does not invent missing prose, infer a synthetic ending, or claim that a transport-limited message is complete.
|
||||
|
||||
## Observable behavior covered
|
||||
|
||||
The focused component tests protect the user-facing boundary rather than the internal call order:
|
||||
|
||||
- eligible assistant text starts the existing full-message load without a click;
|
||||
- loaded Markdown is visible even if an older cached expanded flag is false;
|
||||
- the assistant bubble contains no Show more or Show less control;
|
||||
- Copy and Reply use the complete loaded response;
|
||||
- loading and failure continue to display the transcript text;
|
||||
- mirrored tool replies and messages without a loader remain unchanged;
|
||||
- user-message disclosure behavior stays separate and intact.
|
||||
|
||||
The thread test verifies that one render starts one request, that the complete response replaces the preview, and that later renders do not fetch it again. A second case rejects the request and confirms that the transport text stays readable with no disclosure or error UI.
|
||||
|
||||
## Browser proof
|
||||
|
||||
The browser scenario boots the real Control UI against a mocked Gateway WebSocket. History contains the first 8,000 characters of this report plus the existing truncation state, while chat.message.get returns this complete Markdown document.
|
||||
|
||||
The test waits for the final heading, checks the exact request parameters, confirms that no disclosure buttons exist, and exercises both inline and context-menu actions. Screenshots use a normal 1440×900 viewport in dark and light themes rather than zooming out to make the response look artificially short.
|
||||
|
||||
The before view ends in the middle of the refactor explanation because that is where the transport preview stops. The after view reaches the remaining failure notes, test coverage, scope checks, and final result in the same message bubble.
|
||||
|
||||
## Scope checks
|
||||
|
||||
No gateway handler, protocol type, persistence model, or transport limit changed. The full-message request, expansion cache, identifiers, and response extraction all predate this change. The production diff removes presentation branches instead of adding a second way to retrieve content.
|
||||
|
||||
The change also leaves user-authored long messages alone. Their local disclosure is a presentation choice for text the user submitted, while this assistant path is about showing the complete response once the existing loader has made it available.
|
||||
|
||||
## Manual review notes
|
||||
|
||||
I reviewed the transition at a normal desktop viewport and followed the text rather than relying only on request assertions. The preview remains selectable while loading, the existing headings and code blocks stay mounted, and the message grows in place when the remaining Markdown arrives.
|
||||
|
||||
In both themes, code blocks retain their contrast, long paths wrap without covering the message actions, and the final section remains reachable with ordinary scrolling. No spinner or temporary card shifts the reading position between the preview and the full response.
|
||||
|
||||
I also rejected the mocked full-message request and confirmed that the last received sentence stayed visible. A later host render did not create a tight retry loop, duplicate the message group, or expose an inactive assistant disclosure control.
|
||||
|
||||
## Verification result
|
||||
|
||||
The focused component suite and real-browser mocked-gateway scenario pass with the same structured response. The complete answer is always visible when available, message actions agree with the bubble, both themes remain readable, and the assistant UI exposes no manual expansion control.`;
|
||||
|
||||
let browser: Browser;
|
||||
let server: ControlUiE2eServer;
|
||||
@@ -28,6 +159,19 @@ async function screenshot(page: Page, fileName: string): Promise<void> {
|
||||
await page.screenshot({ animations: "disabled", path: path.join(artifactDir, fileName) });
|
||||
}
|
||||
|
||||
async function setThemeMode(page: Page, mode: "dark" | "light"): Promise<void> {
|
||||
await page.emulateMedia({ colorScheme: mode });
|
||||
await page.evaluate((nextMode) => {
|
||||
const root = document.documentElement;
|
||||
root.dataset.themeMode = nextMode;
|
||||
root.dataset.themeResolved = nextMode;
|
||||
root.classList.toggle("wa-light", nextMode === "light");
|
||||
root.classList.toggle("wa-dark", nextMode === "dark");
|
||||
root.style.colorScheme = nextMode;
|
||||
}, mode);
|
||||
await expect.poll(() => page.locator("html").getAttribute("data-theme-mode")).toBe(mode);
|
||||
}
|
||||
|
||||
async function expectHoverTooltip(button: Locator, text: string): Promise<void> {
|
||||
await button.hover();
|
||||
await expect
|
||||
@@ -121,6 +265,7 @@ describeControlUiE2e("Control UI chat message actions", () => {
|
||||
|
||||
it("offers Reply inline and mirrors every assistant action in the context menu", async () => {
|
||||
const context = await browser.newContext({
|
||||
colorScheme: "dark",
|
||||
locale: "en-US",
|
||||
recordVideo: captureUiProof
|
||||
? { dir: path.join(artifactDir, "video"), size: { height: 900, width: 1440 } }
|
||||
@@ -135,8 +280,9 @@ describeControlUiE2e("Control UI chat message actions", () => {
|
||||
const messageText = "Reply and context menu action proof.";
|
||||
const privateThinking = "private reply reasoning";
|
||||
const visibleThinkingAnswer = "Visible reply context only.";
|
||||
const truncatedPreview = "Truncated assistant preview\n...(truncated)...";
|
||||
const fullAssistantContent = "Complete assistant content loaded inline.";
|
||||
const fullAssistantContent = realisticFullAssistantContent;
|
||||
const truncatedPreview = `${fullAssistantContent.slice(0, transportPreviewLimit)}\n...(truncated)...`;
|
||||
expect(fullAssistantContent.length).toBeGreaterThan(transportPreviewLimit);
|
||||
const gateway = await installMockGateway(page, {
|
||||
historyMessages: [
|
||||
{
|
||||
@@ -172,7 +318,7 @@ describeControlUiE2e("Control UI chat message actions", () => {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: truncatedPreview }],
|
||||
timestamp: Date.now() + 4,
|
||||
__openclaw: { id: "assistant-truncated-proof", seq: 5 },
|
||||
__openclaw: { id: "assistant-refactor-report", seq: 5 },
|
||||
},
|
||||
],
|
||||
methodResponses: {
|
||||
@@ -185,7 +331,7 @@ describeControlUiE2e("Control UI chat message actions", () => {
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.evaluate(() => document.documentElement.setAttribute("data-theme-mode", "dark"));
|
||||
await setThemeMode(page, "dark");
|
||||
const commandPaletteShortcut = process.platform === "darwin" ? "⌘K" : "Ctrl K";
|
||||
await expectHoverTooltip(page.getByRole("button", { name: "New session" }), "New session");
|
||||
await expectHoverTooltip(
|
||||
@@ -328,81 +474,52 @@ describeControlUiE2e("Control UI chat message actions", () => {
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toBe(messageText);
|
||||
|
||||
const expandableBubble = page.locator(
|
||||
'.chat-bubble[data-entry-id="assistant-truncated-proof"]',
|
||||
const fullTextBubble = page.locator(
|
||||
'.chat-bubble[data-entry-id="assistant-refactor-report"]',
|
||||
);
|
||||
const expandableGroup = expandableBubble.locator(
|
||||
const fullTextGroup = fullTextBubble.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({
|
||||
sessionKey: "main",
|
||||
messageId: "assistant-truncated-proof",
|
||||
messageId: "assistant-refactor-report",
|
||||
maxChars: 500_000,
|
||||
});
|
||||
await expandableBubble.getByText(fullAssistantContent, { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
await expandableGroup.hover();
|
||||
await expandableGroup.getByRole("button", { name: "Copy as markdown" }).click();
|
||||
const fullTail = fullTextBubble.getByRole("heading", { name: "Verification result" });
|
||||
await fullTail.waitFor({ state: "visible" });
|
||||
expect(await fullTextBubble.getByRole("button", { name: "Show more" }).count()).toBe(0);
|
||||
expect(await fullTextBubble.getByRole("button", { name: "Show less" }).count()).toBe(0);
|
||||
await fullTail.scrollIntoViewIfNeeded();
|
||||
await screenshot(page, "07-realistic-refactor-dark.png");
|
||||
|
||||
await fullTextGroup.hover();
|
||||
await fullTextGroup.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 fullTextGroup.getByRole("button", { name: "Reply to message" }).click();
|
||||
const fullTextReplyPreview = page.locator(".chat-reply-preview");
|
||||
await expect
|
||||
.poll(() => expandableReplyPreview.locator(".chat-reply-preview__text").textContent())
|
||||
.toBe(fullAssistantContent);
|
||||
await expandableReplyPreview.getByRole("button", { name: "Cancel reply" }).click();
|
||||
.poll(() => fullTextReplyPreview.locator(".chat-reply-preview__text").textContent())
|
||||
.toContain("# Refactor complete: one transcript-loading path");
|
||||
await fullTextReplyPreview.getByRole("button", { name: "Cancel reply" }).click();
|
||||
|
||||
await expandableBubble.click({ button: "right" });
|
||||
await fullTextBubble.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 fullTextBubble.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();
|
||||
.poll(() => fullTextReplyPreview.locator(".chat-reply-preview__text").textContent())
|
||||
.toContain("# Refactor complete: one transcript-loading path");
|
||||
await fullTextReplyPreview.getByRole("button", { name: "Cancel reply" }).click();
|
||||
|
||||
await expandableBubble.getByRole("button", { name: "Show less" }).click();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await expandableBubble.locator(".chat-message-disclosure__content").textContent())
|
||||
?.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.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",
|
||||
});
|
||||
await setThemeMode(page, "light");
|
||||
await fullTail.scrollIntoViewIfNeeded();
|
||||
await screenshot(page, "08-realistic-refactor-light.png");
|
||||
expect(await gateway.getRequests("chat.message.get")).toHaveLength(1);
|
||||
} finally {
|
||||
await context.close();
|
||||
|
||||
@@ -4967,7 +4967,6 @@ export const en: TranslationMap = {
|
||||
activity: "Activity",
|
||||
copySelection: "Copy",
|
||||
forkFromHere: "Fork from here",
|
||||
fullContentLoadFailed: "Could not load the full message.",
|
||||
reply: "Reply",
|
||||
replyToMessage: "Reply to message",
|
||||
replyingTo: "Replying to {name}",
|
||||
|
||||
@@ -119,11 +119,8 @@ function buildGroupedMessageRenderOptions(
|
||||
const messageId = actionDetails.messageId;
|
||||
const expansion = opts.getAssistantMessageExpansion?.(messageId);
|
||||
assistantMessageDisclosure = {
|
||||
expanded: expansion?.status === "loaded" && expansion.expanded,
|
||||
expanded: expansion?.status === "loaded",
|
||||
...(expansion?.status === "loaded" ? { markdown: actionDetails.markdown } : {}),
|
||||
loading: expansion?.status === "loading",
|
||||
error: expansion?.status === "error",
|
||||
onToggle: () => opts.onToggleAssistantMessageExpanded?.(messageId),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -393,6 +390,15 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
senderLabel: who,
|
||||
}),
|
||||
);
|
||||
for (const details of messageActionDetails) {
|
||||
if (
|
||||
details?.shouldFetchFullMessage &&
|
||||
details.messageId &&
|
||||
!opts.getAssistantMessageExpansion?.(details.messageId)
|
||||
) {
|
||||
opts.onToggleAssistantMessageExpanded?.(details.messageId);
|
||||
}
|
||||
}
|
||||
const lastMessageIndex = group.messages.length - 1;
|
||||
const footerActionDetails = messageActionDetails[lastMessageIndex] ?? null;
|
||||
const hasUserFooterActions =
|
||||
|
||||
@@ -123,9 +123,7 @@ export function resolveMessageActionDetails(params: {
|
||||
? params.getAssistantMessageExpansion?.(messageId)
|
||||
: undefined;
|
||||
const visibleMarkdown =
|
||||
expansion?.status === "loaded" && expansion.expanded
|
||||
? stripThinkingTags(expansion.markdown).trim()
|
||||
: previewMarkdown;
|
||||
expansion?.status === "loaded" ? stripThinkingTags(expansion.markdown).trim() : previewMarkdown;
|
||||
const markdown = role === "assistant" ? visibleMarkdown : undefined;
|
||||
const replyText = onReply ? truncateUtf16Safe(visibleMarkdown, 500) : "";
|
||||
if (!markdown && !replyText && !(role === "assistant" && shouldFetchFullMessage)) {
|
||||
@@ -245,9 +243,6 @@ export function renderUserMessageMarkdown(
|
||||
export type AssistantMessageDisclosure = {
|
||||
expanded: boolean;
|
||||
markdown?: string;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
export function renderAssistantMessageMarkdown(
|
||||
@@ -256,35 +251,13 @@ export function renderAssistantMessageMarkdown(
|
||||
disclosure: AssistantMessageDisclosure | undefined,
|
||||
markdownRenderOptions: MarkdownRenderOptions,
|
||||
) {
|
||||
if (!disclosure) {
|
||||
return renderMarkdownText(previewMarkdown, isStreaming, markdownRenderOptions);
|
||||
}
|
||||
const markdown = disclosure.expanded ? (disclosure.markdown ?? previewMarkdown) : previewMarkdown;
|
||||
return html`
|
||||
<div class="chat-message-disclosure ${disclosure.expanded ? "is-expanded" : ""}">
|
||||
<div class="chat-message-disclosure__content">
|
||||
${renderMarkdownText(markdown, isStreaming, markdownRenderOptions)}
|
||||
</div>
|
||||
<div class="chat-message-disclosure__footer">
|
||||
<button
|
||||
class="chat-message-disclosure__toggle"
|
||||
type="button"
|
||||
aria-expanded=${String(disclosure.expanded)}
|
||||
?disabled=${disclosure.loading}
|
||||
@click=${disclosure.onToggle}
|
||||
>
|
||||
${disclosure.loading
|
||||
? t("common.loading")
|
||||
: t(disclosure.expanded ? "chat.messages.showLess" : "chat.messages.showMore")}
|
||||
</button>
|
||||
${disclosure.error
|
||||
? html`<span class="chat-message-disclosure__error" role="status"
|
||||
>${t("chat.messages.fullContentLoadFailed")}</span
|
||||
>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const markdown = disclosure?.expanded
|
||||
? (disclosure.markdown ?? previewMarkdown)
|
||||
: previewMarkdown;
|
||||
const renderOptions = disclosure?.expanded
|
||||
? { ...markdownRenderOptions, mode: "document" as const }
|
||||
: markdownRenderOptions;
|
||||
return renderMarkdownText(markdown, isStreaming, renderOptions);
|
||||
}
|
||||
|
||||
export function renderMarkdownText(
|
||||
|
||||
@@ -5283,33 +5283,30 @@ describe("grouped chat rendering", () => {
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ expanded: false, label: "collapsed" },
|
||||
{ expanded: false, label: "previously collapsed" },
|
||||
{ expanded: true, label: "expanded" },
|
||||
])("copies the currently visible $label assistant message", async ({ expanded }) => {
|
||||
])("copies the full $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;
|
||||
const { container, fullMessage } = renderAssistantDisclosureActionFixture(expanded);
|
||||
|
||||
expect(container.querySelector(".chat-message-disclosure__content")?.textContent).toContain(
|
||||
expectedMessage,
|
||||
);
|
||||
expect(container.querySelector(".chat-text")?.textContent).toContain(fullMessage);
|
||||
expect(container.querySelector(".chat-message-disclosure__toggle")).toBeNull();
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(".chat-group-footer-actions .chat-copy-btn")
|
||||
?.click();
|
||||
|
||||
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith(expectedMessage));
|
||||
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith(fullMessage));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ expanded: false, label: "collapsed" },
|
||||
{ expanded: false, label: "previously collapsed" },
|
||||
{ expanded: true, label: "expanded" },
|
||||
])("replies to the currently visible $label assistant message", ({ expanded }) => {
|
||||
])("replies with the full $label assistant message", ({ expanded }) => {
|
||||
const onReply = vi.fn();
|
||||
const { container, fullMessage, preview } = renderAssistantDisclosureActionFixture(expanded, {
|
||||
const { container, fullMessage } = renderAssistantDisclosureActionFixture(expanded, {
|
||||
onReply,
|
||||
});
|
||||
const expectedMessage = expanded ? fullMessage : preview;
|
||||
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
@@ -5317,9 +5314,9 @@ describe("grouped chat rendering", () => {
|
||||
)
|
||||
?.click();
|
||||
|
||||
expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ text: expectedMessage }));
|
||||
expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ text: fullMessage }));
|
||||
expect(container.querySelector<HTMLElement>(".chat-bubble")?.dataset.messageText).toBe(
|
||||
expectedMessage,
|
||||
fullMessage,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5375,67 +5372,32 @@ describe("grouped chat rendering", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps hidden-only expanded assistant messages recoverable without exposing stale text", () => {
|
||||
it("does not restore a disclosure control for hidden-only loaded assistant text", () => {
|
||||
const onReply = vi.fn();
|
||||
const onToggleAssistantMessageExpanded = vi.fn();
|
||||
const privateThinking = "private expanded reasoning only";
|
||||
const { container, preview } = renderAssistantDisclosureActionFixture(true, {
|
||||
const { container, preview } = renderAssistantDisclosureActionFixture(false, {
|
||||
onReply,
|
||||
onToggleAssistantMessageExpanded,
|
||||
getAssistantMessageExpansion: () => ({
|
||||
status: "loaded",
|
||||
expanded: true,
|
||||
expanded: false,
|
||||
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-text")?.textContent?.trim()).toBe("");
|
||||
expect(container.textContent).not.toContain(privateThinking);
|
||||
expect(container.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);
|
||||
expect(container.querySelector(".chat-message-disclosure__toggle")).toBeNull();
|
||||
expect(onToggleAssistantMessageExpanded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -5457,23 +5419,23 @@ describe("grouped chat rendering", () => {
|
||||
},
|
||||
messageId: "msg-truncated-metadata",
|
||||
},
|
||||
])("renders Show more for assistant truncation detected by $label", ({ message, messageId }) => {
|
||||
const container = document.createElement("div");
|
||||
const onToggleAssistantMessageExpanded = vi.fn();
|
||||
renderAssistantMessage(container, message, {
|
||||
sessionKey: "global",
|
||||
agentId: "work",
|
||||
loadFullAssistantMessage: async () => null,
|
||||
onToggleAssistantMessageExpanded,
|
||||
});
|
||||
])(
|
||||
"loads assistant truncation detected by $label without a disclosure",
|
||||
({ message, messageId }) => {
|
||||
const container = document.createElement("div");
|
||||
const onToggleAssistantMessageExpanded = vi.fn();
|
||||
renderAssistantMessage(container, message, {
|
||||
sessionKey: "global",
|
||||
agentId: "work",
|
||||
loadFullAssistantMessage: async () => null,
|
||||
onToggleAssistantMessageExpanded,
|
||||
});
|
||||
|
||||
const toggle = expectElement(container, ".chat-message-disclosure__toggle", HTMLButtonElement);
|
||||
expect(toggle.textContent?.trim()).toBe("Show more");
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
toggle.click();
|
||||
expect(onToggleAssistantMessageExpanded).toHaveBeenCalledWith(messageId);
|
||||
expect(container.querySelector(".chat-expand-btn")).toBeNull();
|
||||
});
|
||||
expect(onToggleAssistantMessageExpanded).toHaveBeenCalledWith(messageId);
|
||||
expect(container.querySelector(".chat-message-disclosure__toggle")).toBeNull();
|
||||
expect(container.querySelector(".chat-expand-btn")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not add disclosure or canvas actions to non-truncated assistant messages", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("chat transcript row measurement", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("loads a truncated assistant message once across inline collapse and re-expansion", async () => {
|
||||
it("loads a truncated assistant message once and keeps the full text visible", async () => {
|
||||
const transcript = createTestTranscript();
|
||||
const container = document.body.appendChild(document.createElement("div"));
|
||||
const loadFullAssistantMessage = vi.fn().mockResolvedValue({
|
||||
@@ -201,10 +201,6 @@ describe("chat transcript row measurement", () => {
|
||||
transcript.hostConnected();
|
||||
transcript.hostUpdated();
|
||||
|
||||
const showMore = container.querySelector<HTMLButtonElement>(".chat-message-disclosure__toggle");
|
||||
expect(showMore?.textContent?.trim()).toBe("Show more");
|
||||
showMore?.click();
|
||||
|
||||
await vi.waitFor(() => expect(container.textContent).toContain("Complete assistant content."));
|
||||
expect(loadFullAssistantMessage).toHaveBeenCalledOnce();
|
||||
expect(loadFullAssistantMessage).toHaveBeenCalledWith({
|
||||
@@ -214,28 +210,16 @@ describe("chat transcript row measurement", () => {
|
||||
kind: "assistant_message",
|
||||
});
|
||||
|
||||
const showLess = container.querySelector<HTMLButtonElement>(".chat-message-disclosure__toggle");
|
||||
expect(showLess?.textContent?.trim()).toBe("Show less");
|
||||
showLess?.click();
|
||||
expect(container.textContent).toContain("...(truncated)...");
|
||||
expect(container.textContent).not.toContain("Complete assistant content.");
|
||||
|
||||
container.querySelector<HTMLButtonElement>(".chat-message-disclosure__toggle")?.click();
|
||||
expect(container.querySelector(".chat-message-disclosure__toggle")).toBeNull();
|
||||
expect(container.textContent).toContain("Complete assistant content.");
|
||||
expect(loadFullAssistantMessage).toHaveBeenCalledOnce();
|
||||
transcript.hostDisconnected();
|
||||
});
|
||||
|
||||
it("shows a retryable inline error when full assistant content cannot be loaded", async () => {
|
||||
it("keeps transport-cut assistant text as received when full content is unavailable", async () => {
|
||||
const transcript = createTestTranscript();
|
||||
const container = document.body.appendChild(document.createElement("div"));
|
||||
const loadFullAssistantMessage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
message: { role: "assistant", content: "Recovered full content." },
|
||||
});
|
||||
const loadFullAssistantMessage = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
function rerender() {
|
||||
render(renderChatThread(props, transcript), container);
|
||||
transcript.hostUpdated();
|
||||
@@ -256,17 +240,10 @@ describe("chat transcript row measurement", () => {
|
||||
transcript.hostConnected();
|
||||
transcript.hostUpdated();
|
||||
|
||||
container.querySelector<HTMLButtonElement>(".chat-message-disclosure__toggle")?.click();
|
||||
await vi.waitFor(() =>
|
||||
expect(container.textContent).toContain("Could not load the full message."),
|
||||
);
|
||||
const retry = container.querySelector<HTMLButtonElement>(".chat-message-disclosure__toggle");
|
||||
expect(retry?.textContent?.trim()).toBe("Show more");
|
||||
expect(retry?.disabled).toBe(false);
|
||||
|
||||
retry?.click();
|
||||
await vi.waitFor(() => expect(container.textContent).toContain("Recovered full content."));
|
||||
expect(loadFullAssistantMessage).toHaveBeenCalledTimes(2);
|
||||
await vi.waitFor(() => expect(loadFullAssistantMessage).toHaveBeenCalledOnce());
|
||||
expect(container.textContent).toContain("Preview");
|
||||
expect(container.textContent).toContain("...(truncated)...");
|
||||
expect(container.querySelector(".chat-message-disclosure__toggle")).toBeNull();
|
||||
transcript.hostDisconnected();
|
||||
});
|
||||
|
||||
|
||||
@@ -30,12 +30,6 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-message-disclosure__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-message-disclosure__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -63,17 +57,6 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.chat-message-disclosure__toggle:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chat-message-disclosure__error {
|
||||
margin-top: 8px;
|
||||
color: var(--danger);
|
||||
font-size: var(--control-ui-text-sm);
|
||||
}
|
||||
|
||||
.chat-text :where(p, ul, ol, pre, blockquote, table) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user