mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
improve(ui): refine transcript status markers (#125237)
* improve(ui): refine transcript status markers * fix(ui): attach duplicate marker to message content * fix(ui): keep duplicate markers outside rich content --------- Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
0f18bd7fa2
commit
cb5e1d2a2b
@@ -386,6 +386,13 @@ export function renderGroupedMessage(
|
||||
: nothing;
|
||||
|
||||
const duplicateCount = Math.max(1, Math.floor(opts.duplicateCount ?? 1));
|
||||
const duplicateSuffix =
|
||||
duplicateCount > 1
|
||||
? {
|
||||
count: duplicateCount,
|
||||
label: t("chat.messages.duplicatesCollapsed", { count: String(duplicateCount) }),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Pure tool messages (no text/images/attachments) skip the "Tool output"
|
||||
// shell and render as flat kind-aware rows, one disclosure level deep.
|
||||
@@ -531,7 +538,12 @@ export function renderGroupedMessage(
|
||||
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
|
||||
</details>`
|
||||
: markdown
|
||||
? renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions)
|
||||
? renderMarkdownText(
|
||||
markdown,
|
||||
opts.isStreaming,
|
||||
markdownRenderOptions,
|
||||
duplicateSuffix,
|
||||
)
|
||||
: nothing}
|
||||
${hasToolCards
|
||||
? expandsSingleToolCard && singleToolCard
|
||||
@@ -597,15 +609,27 @@ export function renderGroupedMessage(
|
||||
</details>`
|
||||
: markdown
|
||||
? normalizedRole === "user"
|
||||
? renderUserMessageMarkdown(markdown, messageKey, opts, markdownRenderOptions)
|
||||
? renderUserMessageMarkdown(
|
||||
markdown,
|
||||
messageKey,
|
||||
opts,
|
||||
markdownRenderOptions,
|
||||
duplicateSuffix,
|
||||
)
|
||||
: normalizedRole === "assistant"
|
||||
? renderAssistantMessageMarkdown(
|
||||
markdown,
|
||||
opts.isStreaming,
|
||||
opts.assistantMessageDisclosure,
|
||||
markdownRenderOptions,
|
||||
duplicateSuffix,
|
||||
)
|
||||
: renderMarkdownText(
|
||||
markdown,
|
||||
opts.isStreaming,
|
||||
markdownRenderOptions,
|
||||
duplicateSuffix,
|
||||
)
|
||||
: renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions)
|
||||
: nothing}
|
||||
${hasToolCards
|
||||
? renderInlineToolCards(toolCards, {
|
||||
@@ -623,7 +647,7 @@ export function renderGroupedMessage(
|
||||
})
|
||||
: nothing}
|
||||
`}
|
||||
${duplicateCount > 1
|
||||
${duplicateCount > 1 && (!markdown || jsonResult)
|
||||
? html`<div
|
||||
class="chat-duplicate-count"
|
||||
aria-label=${t("chat.messages.duplicatesCollapsed", {
|
||||
|
||||
@@ -23,6 +23,11 @@ export type MessageReplyTarget = {
|
||||
sourceMessageId?: string | null;
|
||||
};
|
||||
|
||||
type DuplicateSuffix = {
|
||||
count: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const MAX_JSON_AUTOPARSE_CHARS = 20_000;
|
||||
|
||||
/**
|
||||
@@ -243,9 +248,10 @@ export function renderUserMessageMarkdown(
|
||||
onToggleUserMessageExpanded?: (messageId: string) => void;
|
||||
},
|
||||
markdownRenderOptions: MarkdownRenderOptions,
|
||||
duplicateSuffix?: DuplicateSuffix,
|
||||
) {
|
||||
if (!opts.onToggleUserMessageExpanded) {
|
||||
return renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions);
|
||||
return renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions, duplicateSuffix);
|
||||
}
|
||||
|
||||
const disclosureId = `user-message:${messageKey}`;
|
||||
@@ -260,7 +266,7 @@ export function renderUserMessageMarkdown(
|
||||
: ""}"
|
||||
>
|
||||
<div class="chat-message-disclosure__content" ${ref(userMessageOverflowRef(expanded))}>
|
||||
${renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions)}
|
||||
${renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions, duplicateSuffix)}
|
||||
</div>
|
||||
<button
|
||||
class="chat-message-disclosure__toggle"
|
||||
@@ -288,6 +294,7 @@ export function renderAssistantMessageMarkdown(
|
||||
isStreaming: boolean,
|
||||
disclosure: AssistantMessageDisclosure | undefined,
|
||||
markdownRenderOptions: MarkdownRenderOptions,
|
||||
duplicateSuffix?: DuplicateSuffix,
|
||||
) {
|
||||
const markdown = disclosure?.expanded
|
||||
? (disclosure.markdown ?? previewMarkdown)
|
||||
@@ -295,7 +302,7 @@ export function renderAssistantMessageMarkdown(
|
||||
const renderOptions = disclosure?.expanded
|
||||
? { ...markdownRenderOptions, mode: "document" as const }
|
||||
: markdownRenderOptions;
|
||||
const text = renderMarkdownText(markdown, isStreaming, renderOptions);
|
||||
const text = renderMarkdownText(markdown, isStreaming, renderOptions, duplicateSuffix);
|
||||
if (!disclosure?.onRetryFullMessage) {
|
||||
return text;
|
||||
}
|
||||
@@ -320,17 +327,43 @@ export function renderMarkdownText(
|
||||
markdown: string,
|
||||
isStreaming: boolean,
|
||||
markdownRenderOptions?: MarkdownRenderOptions,
|
||||
duplicateSuffix?: DuplicateSuffix,
|
||||
) {
|
||||
if (isStreaming) {
|
||||
return html`
|
||||
<div class="chat-text" dir="${detectTextDirection(markdown)}">
|
||||
${unsafeHTML(toStreamingMarkdownHtml(markdown, markdownRenderOptions))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const rendered = isStreaming
|
||||
? toStreamingMarkdownHtml(markdown, markdownRenderOptions)
|
||||
: toSanitizedMarkdownHtml(markdown, markdownRenderOptions);
|
||||
const content = duplicateSuffix ? appendDuplicateSuffix(rendered, duplicateSuffix) : rendered;
|
||||
return html`
|
||||
<div class="chat-text" dir="${detectTextDirection(markdown)}">
|
||||
${unsafeHTML(toSanitizedMarkdownHtml(markdown, markdownRenderOptions))}
|
||||
</div>
|
||||
<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(content)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function appendDuplicateSuffix(rendered: string, suffix: DuplicateSuffix): string {
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = rendered;
|
||||
const terminalBlock = template.content.lastElementChild;
|
||||
const target = terminalBlock ? duplicateSuffixTextOwner(terminalBlock) : null;
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "chat-duplicate-count";
|
||||
badge.setAttribute("aria-label", suffix.label);
|
||||
badge.textContent = `×${suffix.count}`;
|
||||
(target ?? template.content).append(document.createTextNode("\u00a0"), badge);
|
||||
return template.innerHTML;
|
||||
}
|
||||
|
||||
function duplicateSuffixTextOwner(block: Element): Element | null {
|
||||
if (/^(?:P|H[1-6])$/u.test(block.tagName)) {
|
||||
return block;
|
||||
}
|
||||
if (!/^(?:BLOCKQUOTE|LI|OL|UL)$/u.test(block.tagName)) {
|
||||
// Fences, details, raw blocks, and table shells own interactive or copied
|
||||
// content. Keep the status marker after the whole terminal block.
|
||||
return null;
|
||||
}
|
||||
const terminalChild = block.lastElementChild;
|
||||
if (!terminalChild) {
|
||||
return block.textContent?.trim() ? block : null;
|
||||
}
|
||||
return duplicateSuffixTextOwner(terminalChild);
|
||||
}
|
||||
|
||||
@@ -747,6 +747,67 @@ describe("grouped chat rendering", () => {
|
||||
expect(badge?.getAttribute("aria-label")).toBe("4 consecutive identical messages collapsed");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ markdown: "Final paragraph", owner: "p" },
|
||||
{ markdown: "- first\n- final", owner: "li:last-child" },
|
||||
{ markdown: "> quoted ending", owner: "blockquote p" },
|
||||
])(
|
||||
"attaches a duplicate marker to the terminal textual owner in $owner",
|
||||
({ markdown: markdownText, owner }) => {
|
||||
const container = document.createElement("div");
|
||||
markdownRenderMock.mockImplementationOnce(renderMarkdownHtml);
|
||||
renderAssistantMessageEntries(container, [
|
||||
{
|
||||
key: "assistant-duplicate",
|
||||
message: createAssistantMessage(markdownText, { timestamp: 1 }),
|
||||
duplicateCount: 3,
|
||||
},
|
||||
]);
|
||||
|
||||
const target = expectElement(container, owner, HTMLElement);
|
||||
expect(target.querySelector(":scope > .chat-duplicate-count")?.textContent).toBe("×3");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ label: "fence", markdown: "Paragraph\n\n```ts\nconst value = 1;\n```", terminal: "pre" },
|
||||
{
|
||||
label: "compact details",
|
||||
markdown: "<details><summary>More</summary>body</details>",
|
||||
terminal: "details",
|
||||
},
|
||||
{
|
||||
label: "block details",
|
||||
markdown: "<details>\n<summary>More</summary>\n\nbody\n</details>",
|
||||
terminal: "details",
|
||||
},
|
||||
{
|
||||
label: "table",
|
||||
markdown: "| Name | Value |\n| --- | --- |\n| one | two |",
|
||||
terminal: ".markdown-table",
|
||||
},
|
||||
])(
|
||||
"keeps a duplicate marker outside terminal $label content",
|
||||
({ markdown: markdownText, terminal }) => {
|
||||
const container = document.createElement("div");
|
||||
markdownRenderMock.mockImplementationOnce(renderMarkdownHtml);
|
||||
renderAssistantMessageEntries(container, [
|
||||
{
|
||||
key: "assistant-duplicate",
|
||||
message: createAssistantMessage(markdownText, { timestamp: 1 }),
|
||||
duplicateCount: 3,
|
||||
},
|
||||
]);
|
||||
|
||||
const chatText = expectElement(container, ".chat-text", HTMLDivElement);
|
||||
const terminalBlock = expectElement(chatText, terminal, HTMLElement);
|
||||
expect(terminalBlock.querySelector(".chat-duplicate-count")).toBeNull();
|
||||
expect(chatText.querySelector(":scope > .chat-duplicate-count")?.textContent).toBe("×3");
|
||||
expect(chatText.querySelector("summary")?.textContent ?? "").not.toContain("×3");
|
||||
expect(chatText.querySelector("td:last-child")?.textContent ?? "").not.toContain("×3");
|
||||
},
|
||||
);
|
||||
|
||||
it("does not render the stale assistant read-aloud footer action", () => {
|
||||
const container = document.createElement("div");
|
||||
renderAssistantMessage(
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
.chat-divider {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 18px 8px;
|
||||
margin: var(--space-7) 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
@@ -681,9 +681,9 @@ img.chat-avatar.chat-avatar--logo {
|
||||
.chat-duplicate-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
align-self: baseline;
|
||||
min-height: 22px;
|
||||
margin-top: 8px;
|
||||
margin-top: 0;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -692,6 +692,8 @@ img.chat-avatar.chat-avatar--logo {
|
||||
font-size: 12px; /* was 11px */
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
vertical-align: 0.08em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-group.user .chat-duplicate-count {
|
||||
|
||||
@@ -1155,7 +1155,7 @@
|
||||
.chat-working-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
gap: var(--space-2);
|
||||
min-height: 28px;
|
||||
animation: chatWorkingIndicatorEnter 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user