mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:10:43 +00:00
perf: split chat UI test dependencies
This commit is contained in:
101
ui/src/ui/chat/chat-avatar.test.ts
Normal file
101
ui/src/ui/chat/chat-avatar.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderChatAvatar } from "./chat-avatar.ts";
|
||||
|
||||
vi.mock("../views/agents-utils.ts", () => {
|
||||
const isRenderableControlUiAvatarUrl = (value: string) =>
|
||||
/^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//"));
|
||||
|
||||
return {
|
||||
assistantAvatarFallbackUrl: () => "/openclaw-molty.png",
|
||||
isRenderableControlUiAvatarUrl,
|
||||
resolveAssistantTextAvatar: (value: string | null | undefined) => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed || trimmed === "A") {
|
||||
return null;
|
||||
}
|
||||
if (trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
trimmed.length > 8 ||
|
||||
/\s/.test(trimmed) ||
|
||||
/[\\/.:]/.test(trimmed) ||
|
||||
/[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u.test(trimmed)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
},
|
||||
resolveChatAvatarRenderUrl: (
|
||||
candidate: string | null | undefined,
|
||||
agent: { identity?: { avatar?: string; avatarUrl?: string } },
|
||||
) => {
|
||||
if (typeof candidate === "string" && candidate.startsWith("blob:")) {
|
||||
return candidate;
|
||||
}
|
||||
for (const value of [candidate, agent.identity?.avatarUrl, agent.identity?.avatar]) {
|
||||
if (typeof value === "string" && isRenderableControlUiAvatarUrl(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function renderAvatar(params: Parameters<typeof renderChatAvatar>) {
|
||||
const container = document.createElement("div");
|
||||
render(renderChatAvatar(...params), container);
|
||||
return container.querySelector<HTMLElement>(".chat-avatar");
|
||||
}
|
||||
|
||||
describe("renderChatAvatar", () => {
|
||||
it("uses the assistant fallback when no assistant avatar is configured", () => {
|
||||
const avatar = renderAvatar(["assistant"]);
|
||||
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.getAttribute("src")).toBe("/openclaw-molty.png");
|
||||
});
|
||||
|
||||
it("renders assistant fallback, blob image, and text avatars", () => {
|
||||
const remoteAvatar = renderAvatar([
|
||||
"assistant",
|
||||
{ avatar: "https://example.com/avatar.png", name: "Val" },
|
||||
]);
|
||||
expect(remoteAvatar?.getAttribute("src")).toBe("/openclaw-molty.png");
|
||||
|
||||
const blobAvatar = renderAvatar(["assistant", { avatar: "blob:managed-image", name: "Val" }]);
|
||||
expect(blobAvatar?.tagName).toBe("IMG");
|
||||
expect(blobAvatar?.getAttribute("src")).toBe("blob:managed-image");
|
||||
|
||||
const textAvatar = renderAvatar(["assistant", { avatar: "VC", name: "Val" }]);
|
||||
expect(textAvatar?.tagName).toBe("DIV");
|
||||
expect(textAvatar?.textContent).toContain("VC");
|
||||
expect(textAvatar?.getAttribute("aria-label")).toBe("Val");
|
||||
});
|
||||
|
||||
it("uses the assistant fallback while authenticated avatar routes are loading", () => {
|
||||
const avatar = renderAvatar([
|
||||
"assistant",
|
||||
{ avatar: "/avatar/main", name: "OpenClaw" },
|
||||
undefined,
|
||||
"",
|
||||
"session-token",
|
||||
]);
|
||||
|
||||
expect(avatar?.getAttribute("src")).toBe("/openclaw-molty.png");
|
||||
});
|
||||
|
||||
it("renders local user image and text avatars", () => {
|
||||
const imageAvatar = renderAvatar(["user", undefined, { name: "Buns", avatar: "/avatar/user" }]);
|
||||
expect(imageAvatar?.getAttribute("src")).toBe("/avatar/user");
|
||||
expect(imageAvatar?.getAttribute("alt")).toBe("Buns");
|
||||
|
||||
const textAvatar = renderAvatar(["user", undefined, { name: "Buns", avatar: "AB" }]);
|
||||
expect(textAvatar?.tagName).toBe("DIV");
|
||||
expect(textAvatar?.textContent).toContain("AB");
|
||||
});
|
||||
});
|
||||
127
ui/src/ui/chat/chat-avatar.ts
Normal file
127
ui/src/ui/chat/chat-avatar.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { html } from "lit";
|
||||
import type { AssistantIdentity } from "../assistant-identity.ts";
|
||||
import {
|
||||
resolveLocalUserAvatarText,
|
||||
resolveLocalUserAvatarUrl,
|
||||
resolveLocalUserName,
|
||||
} from "../user-identity.ts";
|
||||
import {
|
||||
assistantAvatarFallbackUrl,
|
||||
isRenderableControlUiAvatarUrl,
|
||||
resolveAssistantTextAvatar,
|
||||
} from "../views/agents-utils.ts";
|
||||
import { normalizeRoleForGrouping } from "./message-normalizer.ts";
|
||||
|
||||
export function renderChatAvatar(
|
||||
role: string,
|
||||
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
|
||||
user?: { name?: string | null; avatar?: string | null },
|
||||
basePath?: string,
|
||||
authToken?: string | null,
|
||||
) {
|
||||
const normalized = normalizeRoleForGrouping(role);
|
||||
const assistantName = assistant?.name?.trim() || "Assistant";
|
||||
const assistantAvatar = assistant?.avatar?.trim() || "";
|
||||
const assistantAvatarText = resolveAssistantTextAvatar(assistantAvatar);
|
||||
const assistantFallbackAvatar = assistantAvatarFallbackUrl(basePath ?? "");
|
||||
const userName = resolveLocalUserName(user);
|
||||
const userAvatarUrl = resolveLocalUserAvatarUrl(user);
|
||||
const userAvatarText = resolveLocalUserAvatarText(user);
|
||||
const initial =
|
||||
normalized === "user"
|
||||
? html`
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M20 21a8 8 0 1 0-16 0" />
|
||||
</svg>
|
||||
`
|
||||
: normalized === "assistant"
|
||||
? html`
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||||
<path d="M12 2l2.4 7.2H22l-6 4.8 2.4 7.2L12 16l-6.4 5.2L8 14 2 9.2h7.6z" />
|
||||
</svg>
|
||||
`
|
||||
: normalized === "tool"
|
||||
? html`
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||||
<path
|
||||
d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53a7.76 7.76 0 0 0 .07-1 7.76 7.76 0 0 0-.07-.97l2.11-1.63a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.15 7.15 0 0 0-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4a.49.49 0 0 0-.49.42l-.38 2.65a7.15 7.15 0 0 0-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.49.49 0 0 0 .12.64L4.57 11a7.9 7.9 0 0 0 0 1.94l-2.11 1.69a.49.49 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.72 1.69.98l.38 2.65c.05.24.26.42.49.42h4c.23 0 .44-.18.49-.42l.38-2.65a7.15 7.15 0 0 0 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.49.49 0 0 0-.12-.64z"
|
||||
/>
|
||||
</svg>
|
||||
`
|
||||
: html`
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<text
|
||||
x="12"
|
||||
y="16.5"
|
||||
text-anchor="middle"
|
||||
font-size="14"
|
||||
font-weight="600"
|
||||
fill="var(--bg, #fff)"
|
||||
>
|
||||
?
|
||||
</text>
|
||||
</svg>
|
||||
`;
|
||||
const className =
|
||||
normalized === "user"
|
||||
? "user"
|
||||
: normalized === "assistant"
|
||||
? "assistant"
|
||||
: normalized === "tool"
|
||||
? "tool"
|
||||
: "other";
|
||||
|
||||
if (normalized === "user" && userAvatarUrl) {
|
||||
return html`<img class="chat-avatar ${className}" src="${userAvatarUrl}" alt="${userName}" />`;
|
||||
}
|
||||
|
||||
if (normalized === "user" && userAvatarText) {
|
||||
return html`<div class="chat-avatar ${className}" aria-label="${userName}">
|
||||
${userAvatarText}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (assistantAvatar && normalized === "assistant") {
|
||||
if (isAvatarUrl(assistantAvatar)) {
|
||||
if (authToken?.trim() && assistantAvatar.startsWith("/")) {
|
||||
return html`<img
|
||||
class="chat-avatar ${className} chat-avatar--logo"
|
||||
src="${assistantFallbackAvatar}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
return html`<img
|
||||
class="chat-avatar ${className}"
|
||||
src="${assistantAvatar}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
if (assistantAvatarText) {
|
||||
return html`<div class="chat-avatar ${className}" aria-label="${assistantName}">
|
||||
${assistantAvatarText}
|
||||
</div>`;
|
||||
}
|
||||
return html`<img
|
||||
class="chat-avatar ${className} chat-avatar--logo"
|
||||
src="${assistantFallbackAvatar}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
if (normalized === "assistant") {
|
||||
return html`<img
|
||||
class="chat-avatar ${className} chat-avatar--logo"
|
||||
src="${assistantFallbackAvatar}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
||||
}
|
||||
|
||||
function isAvatarUrl(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed);
|
||||
}
|
||||
67
ui/src/ui/chat/chat-queue.ts
Normal file
67
ui/src/ui/chat/chat-queue.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { icons } from "../icons.ts";
|
||||
import type { ChatQueueItem } from "../ui-types.ts";
|
||||
|
||||
export type ChatQueueProps = {
|
||||
queue: ChatQueueItem[];
|
||||
canAbort?: boolean;
|
||||
onQueueSteer?: (id: string) => void;
|
||||
onQueueRemove: (id: string) => void;
|
||||
};
|
||||
|
||||
export function renderChatQueue(props: ChatQueueProps) {
|
||||
if (!props.queue.length) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="chat-queue" role="status" aria-live="polite">
|
||||
<div class="chat-queue__title">Queued (${props.queue.length})</div>
|
||||
<div class="chat-queue__list">
|
||||
${props.queue.map(
|
||||
(item) => html`
|
||||
<div
|
||||
class="chat-queue__item ${item.kind === "steered" ? "chat-queue__item--steered" : ""}"
|
||||
>
|
||||
<div class="chat-queue__main">
|
||||
${item.kind === "steered"
|
||||
? html`<span class="chat-queue__badge">Steered</span>`
|
||||
: nothing}
|
||||
<div class="chat-queue__text">
|
||||
${item.text ||
|
||||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-queue__actions">
|
||||
${props.canAbort &&
|
||||
props.onQueueSteer &&
|
||||
item.kind !== "steered" &&
|
||||
!item.localCommandName
|
||||
? html`
|
||||
<button
|
||||
class="btn chat-queue__steer"
|
||||
type="button"
|
||||
title="Steer now"
|
||||
aria-label="Steer queued message"
|
||||
@click=${() => props.onQueueSteer?.(item.id)}
|
||||
>
|
||||
${icons.cornerDownRight}
|
||||
<span>Steer</span>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
<button
|
||||
class="btn chat-queue__remove"
|
||||
type="button"
|
||||
aria-label="Remove queued message"
|
||||
@click=${() => props.onQueueRemove(item.id)}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
29
ui/src/ui/chat/chat-sidebar-raw.ts
Normal file
29
ui/src/ui/chat/chat-sidebar-raw.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { SidebarContent } from "../sidebar-content.ts";
|
||||
|
||||
function toPlainTextCodeFence(value: string, language = ""): string {
|
||||
const fenceHeader = language ? `\`\`\`${language}` : "```";
|
||||
return `${fenceHeader}\n${value}\n\`\`\``;
|
||||
}
|
||||
|
||||
export function buildRawSidebarContent(
|
||||
content: SidebarContent | null | undefined,
|
||||
): SidebarContent | null {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
if (content.kind === "markdown") {
|
||||
const rawText = content.rawText ?? content.content;
|
||||
return {
|
||||
kind: "markdown",
|
||||
content: toPlainTextCodeFence(rawText),
|
||||
rawText,
|
||||
};
|
||||
}
|
||||
if (content.rawText?.trim()) {
|
||||
return {
|
||||
kind: "markdown",
|
||||
content: toPlainTextCodeFence(content.rawText, "json"),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
88
ui/src/ui/chat/chat-welcome.ts
Normal file
88
ui/src/ui/chat/chat-welcome.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { html } from "lit";
|
||||
import {
|
||||
agentLogoUrl,
|
||||
assistantAvatarFallbackUrl,
|
||||
resolveChatAvatarRenderUrl,
|
||||
resolveAssistantTextAvatar,
|
||||
} from "../views/agents-utils.ts";
|
||||
|
||||
export type ChatWelcomeProps = {
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAvatarUrl?: string | null;
|
||||
basePath?: string;
|
||||
onDraftChange: (next: string) => void;
|
||||
onSend: () => void;
|
||||
};
|
||||
|
||||
const WELCOME_SUGGESTIONS = [
|
||||
"What can you do?",
|
||||
"Summarize my recent sessions",
|
||||
"Help me configure a channel",
|
||||
"Check system health",
|
||||
];
|
||||
|
||||
function resolveAssistantAvatarUrl(
|
||||
props: Pick<ChatWelcomeProps, "assistantAvatar" | "assistantAvatarUrl">,
|
||||
): string | null {
|
||||
return resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
|
||||
identity: {
|
||||
avatar: props.assistantAvatar ?? undefined,
|
||||
avatarUrl: props.assistantAvatarUrl ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAssistantDisplayAvatar(
|
||||
props: Pick<ChatWelcomeProps, "assistantAvatar" | "assistantAvatarUrl">,
|
||||
): string | null {
|
||||
return resolveAssistantAvatarUrl(props) ?? resolveAssistantTextAvatar(props.assistantAvatar);
|
||||
}
|
||||
|
||||
export function renderWelcomeState(props: ChatWelcomeProps) {
|
||||
const name = props.assistantName || "Assistant";
|
||||
const avatar = resolveAssistantAvatarUrl(props);
|
||||
const avatarText = avatar ? null : resolveAssistantTextAvatar(props.assistantAvatar);
|
||||
const fallbackAvatarUrl = assistantAvatarFallbackUrl(props.basePath ?? "");
|
||||
const logoUrl = agentLogoUrl(props.basePath ?? "");
|
||||
|
||||
return html`
|
||||
<div class="agent-chat__welcome" style="--agent-color: var(--accent)">
|
||||
<div class="agent-chat__welcome-glow"></div>
|
||||
${avatar
|
||||
? html`<img
|
||||
src=${avatar}
|
||||
alt=${name}
|
||||
style="width:56px; height:56px; border-radius:50%; object-fit:cover;"
|
||||
/>`
|
||||
: avatarText
|
||||
? html`<div class="agent-chat__avatar agent-chat__avatar--text" aria-label=${name}>
|
||||
${avatarText}
|
||||
</div>`
|
||||
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
|
||||
<img src=${fallbackAvatarUrl} alt=${name} />
|
||||
</div>`}
|
||||
<h2>${name}</h2>
|
||||
<div class="agent-chat__badges">
|
||||
<span class="agent-chat__badge"><img src=${logoUrl} alt="" /> Ready to chat</span>
|
||||
</div>
|
||||
<p class="agent-chat__hint">Type a message below · <kbd>/</kbd> for commands</p>
|
||||
<div class="agent-chat__suggestions">
|
||||
${WELCOME_SUGGESTIONS.map(
|
||||
(text) => html`
|
||||
<button
|
||||
type="button"
|
||||
class="agent-chat__suggestion"
|
||||
@click=${() => {
|
||||
props.onDraftChange(text);
|
||||
props.onSend();
|
||||
}}
|
||||
>
|
||||
${text}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -2,17 +2,25 @@
|
||||
|
||||
import { html, render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getSafeLocalStorage } from "../../local-storage.ts";
|
||||
import type { MessageGroup } from "../types/chat-types.ts";
|
||||
import {
|
||||
formatChatTimestampForDisplay,
|
||||
renderMessageGroup,
|
||||
renderStreamingGroup,
|
||||
resolveAssistantTextAvatar,
|
||||
resetAssistantAttachmentAvailabilityCacheForTest,
|
||||
} from "./grouped-render.ts";
|
||||
import { normalizeMessage } from "./message-normalizer.ts";
|
||||
|
||||
const localStorageValues = vi.hoisted(() => new Map<string, string>());
|
||||
|
||||
vi.mock("../../local-storage.ts", () => ({
|
||||
getSafeLocalStorage: () => ({
|
||||
getItem: (key: string) => localStorageValues.get(key) ?? null,
|
||||
removeItem: (key: string) => localStorageValues.delete(key),
|
||||
setItem: (key: string, value: string) => localStorageValues.set(key, value),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../markdown.ts", () => ({
|
||||
toSanitizedMarkdownHtml: (value: string) => value,
|
||||
}));
|
||||
@@ -69,6 +77,14 @@ vi.mock("../views/agents-utils.ts", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./chat-avatar.ts", () => ({
|
||||
renderChatAvatar: (role: string) => {
|
||||
const element = document.createElement("div");
|
||||
element.className = `chat-avatar ${role}`;
|
||||
return element;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../tool-display.ts", () => ({
|
||||
formatToolDetail: () => undefined,
|
||||
resolveToolDisplay: ({ name }: { name: string }) => ({
|
||||
@@ -202,11 +218,7 @@ function renderMessageGroups(
|
||||
}
|
||||
|
||||
function clearDeleteConfirmSkip() {
|
||||
try {
|
||||
getSafeLocalStorage()?.removeItem("openclaw:skipDeleteConfirm");
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
localStorageValues.delete("openclaw:skipDeleteConfirm");
|
||||
}
|
||||
|
||||
async function flushAssistantAttachmentAvailabilityChecks() {
|
||||
@@ -221,38 +233,6 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("grouped chat rendering", () => {
|
||||
it("falls back to the logo while authenticated avatar routes are loading", () => {
|
||||
const container = document.createElement("div");
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
{
|
||||
assistantAvatar: "/avatar/main",
|
||||
assistantAttachmentAuthToken: "session-token",
|
||||
},
|
||||
);
|
||||
|
||||
const img = container.querySelector("img.chat-avatar");
|
||||
expect(img?.getAttribute("src")).toBe("/openclaw-molty.png");
|
||||
});
|
||||
|
||||
it("uses the Molty png as the default assistant transcript avatar", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
renderAssistantMessage(container, {
|
||||
role: "assistant",
|
||||
content: "hello",
|
||||
timestamp: 1000,
|
||||
});
|
||||
|
||||
const avatar = container.querySelector<HTMLImageElement>(".chat-avatar.assistant");
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.getAttribute("src")).toBe("/openclaw-molty.png");
|
||||
});
|
||||
|
||||
it("positions delete confirm by message side", () => {
|
||||
const renderDeletable = (role: "user" | "assistant") => {
|
||||
const container = document.createElement("div");
|
||||
@@ -297,41 +277,6 @@ describe("grouped chat rendering", () => {
|
||||
expect(assistantConfirm?.classList.contains("chat-delete-confirm--right")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders assistant avatar variants", () => {
|
||||
const renderAvatar = (assistantAvatar: string) => {
|
||||
const container = document.createElement("div");
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
role: "assistant",
|
||||
content: "hello",
|
||||
timestamp: 1000,
|
||||
},
|
||||
{ assistantAvatar, assistantName: "Val" },
|
||||
);
|
||||
return container.querySelector<HTMLElement>(".chat-avatar.assistant");
|
||||
};
|
||||
|
||||
const remoteAvatar = renderAvatar("https://example.com/avatar.png");
|
||||
expect(remoteAvatar?.getAttribute("src")).toBe("/openclaw-molty.png");
|
||||
|
||||
const blobAvatar = renderAvatar("blob:managed-image");
|
||||
expect(blobAvatar?.tagName).toBe("IMG");
|
||||
expect(blobAvatar?.getAttribute("src")).toBe("blob:managed-image");
|
||||
|
||||
const textAvatar = renderAvatar("VC");
|
||||
expect(textAvatar?.tagName).toBe("DIV");
|
||||
expect(textAvatar?.textContent).toContain("VC");
|
||||
expect(textAvatar?.getAttribute("aria-label")).toBe("Val");
|
||||
});
|
||||
|
||||
it("rejects unsafe invisible controls in assistant text avatars", () => {
|
||||
expect(resolveAssistantTextAvatar("VC")).toBe("VC");
|
||||
expect(resolveAssistantTextAvatar("\u{1F43E}")).toBe("\u{1F43E}");
|
||||
expect(resolveAssistantTextAvatar("V\u202eC")).toBeNull();
|
||||
expect(resolveAssistantTextAvatar("V\u200bC")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders assistant context usage from input and cache tokens", () => {
|
||||
const renderUsage = (usage: Record<string, number>, contextWindow: number) => {
|
||||
const container = document.createElement("div");
|
||||
@@ -378,7 +323,7 @@ describe("grouped chat rendering", () => {
|
||||
expect(outputHeavy.querySelector(".msg-meta__ctx")?.textContent).toBe("10% ctx");
|
||||
});
|
||||
|
||||
it("renders full dates with message timestamps", () => {
|
||||
it("renders full dates with message and streaming timestamps", () => {
|
||||
const container = document.createElement("div");
|
||||
const timestamp = Date.UTC(2026, 3, 24, 18, 30);
|
||||
|
||||
@@ -394,19 +339,14 @@ describe("grouped chat rendering", () => {
|
||||
expect(time?.dateTime).toBe(display.dateTime);
|
||||
expect(time?.textContent?.trim()).toBe(display.label);
|
||||
expect(time?.getAttribute("title")).toBe(display.title);
|
||||
});
|
||||
|
||||
it("renders full dates with streaming timestamps", () => {
|
||||
const container = document.createElement("div");
|
||||
const timestamp = Date.UTC(2026, 3, 24, 18, 30);
|
||||
|
||||
render(renderStreamingGroup("Working", timestamp), container);
|
||||
|
||||
const time = container.querySelector<HTMLTimeElement>(".chat-group-timestamp");
|
||||
expect(time?.textContent?.trim()).toBe(formatChatTimestampForDisplay(timestamp).label);
|
||||
const streamingTime = container.querySelector<HTMLTimeElement>(".chat-group-timestamp");
|
||||
expect(streamingTime?.textContent?.trim()).toBe(display.label);
|
||||
});
|
||||
|
||||
it("renders configured local user names and avatar variants", () => {
|
||||
it("renders configured local user names", () => {
|
||||
const renderUser = (opts: Partial<RenderMessageGroupOptions>) => {
|
||||
const container = document.createElement("div");
|
||||
renderGroupedMessage(
|
||||
@@ -426,18 +366,8 @@ describe("grouped chat rendering", () => {
|
||||
const sender = named.querySelector<HTMLElement>(".chat-group.user .chat-sender-name");
|
||||
expect(sender?.textContent).toBe("Buns");
|
||||
|
||||
for (const src of ["data:image/png;base64,AAA", "/avatar/user"]) {
|
||||
const container = renderUser({ userName: "Buns", userAvatar: src });
|
||||
const avatar = container.querySelector<HTMLImageElement>(".chat-avatar.user");
|
||||
expect(avatar?.getAttribute("src")).toBe(src);
|
||||
expect(avatar?.getAttribute("alt")).toBe("Buns");
|
||||
}
|
||||
|
||||
const textAvatar = renderUser({ userAvatar: "🦞" }).querySelector<HTMLElement>(
|
||||
".chat-avatar.user",
|
||||
);
|
||||
expect(textAvatar?.tagName).toBe("DIV");
|
||||
expect(textAvatar?.textContent).toContain("🦞");
|
||||
const avatar = named.querySelector<HTMLElement>(".chat-avatar.user");
|
||||
expect(avatar?.tagName).toBe("DIV");
|
||||
});
|
||||
|
||||
it("keeps inline tool cards collapsed by default and renders expanded state", () => {
|
||||
@@ -643,7 +573,7 @@ describe("grouped chat rendering", () => {
|
||||
expect(container.textContent).not.toContain("MEDIA:https://example.com/photo.png");
|
||||
});
|
||||
|
||||
it("renders allowed transcript images and skips blocked/non-image media", () => {
|
||||
it("renders allowed transcript and content image variants", () => {
|
||||
const renderUserMedia = (message: unknown) => {
|
||||
const container = document.createElement("div");
|
||||
renderGroupedMessage(container, message, "user", {
|
||||
@@ -699,6 +629,22 @@ describe("grouped chat rendering", () => {
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Fsecond.jpg&token=session-token",
|
||||
]);
|
||||
|
||||
const assistantContainer = document.createElement("div");
|
||||
renderAssistantMessage(
|
||||
assistantContainer,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "input_image", image_url: "data:image/png;base64,cG5n" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{ showToolCalls: false },
|
||||
);
|
||||
expect(
|
||||
assistantContainer
|
||||
.querySelector<HTMLImageElement>(".chat-message-image")
|
||||
?.getAttribute("src"),
|
||||
).toBe("data:image/png;base64,cG5n");
|
||||
|
||||
container = renderUserMedia({
|
||||
id: "user-history-image-blocked",
|
||||
role: "user",
|
||||
@@ -721,23 +667,6 @@ describe("grouped chat rendering", () => {
|
||||
expect(container.querySelector(".chat-message-image")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders legacy input_image image_url blocks", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "input_image", image_url: "data:image/png;base64,cG5n" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{ showToolCalls: false },
|
||||
);
|
||||
|
||||
const image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||||
expect(image?.getAttribute("src")).toBe("data:image/png;base64,cG5n");
|
||||
});
|
||||
|
||||
it("fetches managed chat images with auth and renders blob previews", async () => {
|
||||
resetAssistantAttachmentAvailabilityCacheForTest();
|
||||
const objectUrl = "blob:managed-image";
|
||||
|
||||
@@ -15,17 +15,9 @@ import type {
|
||||
NormalizedMessage,
|
||||
ToolCard,
|
||||
} from "../types/chat-types.ts";
|
||||
import {
|
||||
resolveLocalUserAvatarText,
|
||||
resolveLocalUserAvatarUrl,
|
||||
resolveLocalUserName,
|
||||
} from "../user-identity.ts";
|
||||
import {
|
||||
assistantAvatarFallbackUrl,
|
||||
isRenderableControlUiAvatarUrl,
|
||||
resolveAssistantTextAvatar,
|
||||
} from "../views/agents-utils.ts";
|
||||
import { resolveLocalUserName } from "../user-identity.ts";
|
||||
export { resolveAssistantTextAvatar } from "../views/agents-utils.ts";
|
||||
import { renderChatAvatar } from "./chat-avatar.ts";
|
||||
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
|
||||
import {
|
||||
extractTextCached,
|
||||
@@ -270,7 +262,7 @@ export function renderReadingIndicatorGroup(
|
||||
) {
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
${renderAvatar("assistant", assistant, undefined, basePath, authToken)}
|
||||
${renderChatAvatar("assistant", assistant, undefined, basePath, authToken)}
|
||||
<div class="chat-group-messages">
|
||||
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
|
||||
<span class="chat-reading-indicator__dots">
|
||||
@@ -294,7 +286,7 @@ export function renderStreamingGroup(
|
||||
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
${renderAvatar("assistant", assistant, undefined, basePath, authToken)}
|
||||
${renderChatAvatar("assistant", assistant, undefined, basePath, authToken)}
|
||||
<div class="chat-group-messages">
|
||||
${renderGroupedMessage(
|
||||
{
|
||||
@@ -370,7 +362,7 @@ export function renderMessageGroup(
|
||||
|
||||
return html`
|
||||
<div class="chat-group ${roleClass}">
|
||||
${renderAvatar(
|
||||
${renderChatAvatar(
|
||||
group.role,
|
||||
{
|
||||
name: assistantName,
|
||||
@@ -679,120 +671,6 @@ function renderTtsButton(group: MessageGroup) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAvatar(
|
||||
role: string,
|
||||
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
|
||||
user?: { name?: string | null; avatar?: string | null },
|
||||
basePath?: string,
|
||||
authToken?: string | null,
|
||||
) {
|
||||
const normalized = normalizeRoleForGrouping(role);
|
||||
const assistantName = assistant?.name?.trim() || "Assistant";
|
||||
const assistantAvatar = assistant?.avatar?.trim() || "";
|
||||
const assistantAvatarText = resolveAssistantTextAvatar(assistantAvatar);
|
||||
const assistantFallbackAvatar = assistantAvatarFallbackUrl(basePath ?? "");
|
||||
const userName = resolveLocalUserName(user);
|
||||
const userAvatarUrl = resolveLocalUserAvatarUrl(user);
|
||||
const userAvatarText = resolveLocalUserAvatarText(user);
|
||||
const initial =
|
||||
normalized === "user"
|
||||
? html`
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M20 21a8 8 0 1 0-16 0" />
|
||||
</svg>
|
||||
`
|
||||
: normalized === "assistant"
|
||||
? html`
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||||
<path d="M12 2l2.4 7.2H22l-6 4.8 2.4 7.2L12 16l-6.4 5.2L8 14 2 9.2h7.6z" />
|
||||
</svg>
|
||||
`
|
||||
: normalized === "tool"
|
||||
? html`
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||||
<path
|
||||
d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53a7.76 7.76 0 0 0 .07-1 7.76 7.76 0 0 0-.07-.97l2.11-1.63a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.15 7.15 0 0 0-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4a.49.49 0 0 0-.49.42l-.38 2.65a7.15 7.15 0 0 0-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.49.49 0 0 0 .12.64L4.57 11a7.9 7.9 0 0 0 0 1.94l-2.11 1.69a.49.49 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.72 1.69.98l.38 2.65c.05.24.26.42.49.42h4c.23 0 .44-.18.49-.42l.38-2.65a7.15 7.15 0 0 0 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.49.49 0 0 0-.12-.64z"
|
||||
/>
|
||||
</svg>
|
||||
`
|
||||
: html`
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<text
|
||||
x="12"
|
||||
y="16.5"
|
||||
text-anchor="middle"
|
||||
font-size="14"
|
||||
font-weight="600"
|
||||
fill="var(--bg, #fff)"
|
||||
>
|
||||
?
|
||||
</text>
|
||||
</svg>
|
||||
`;
|
||||
const className =
|
||||
normalized === "user"
|
||||
? "user"
|
||||
: normalized === "assistant"
|
||||
? "assistant"
|
||||
: normalized === "tool"
|
||||
? "tool"
|
||||
: "other";
|
||||
|
||||
if (normalized === "user" && userAvatarUrl) {
|
||||
return html`<img class="chat-avatar ${className}" src="${userAvatarUrl}" alt="${userName}" />`;
|
||||
}
|
||||
|
||||
if (normalized === "user" && userAvatarText) {
|
||||
return html`<div class="chat-avatar ${className}" aria-label="${userName}">
|
||||
${userAvatarText}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (assistantAvatar && normalized === "assistant") {
|
||||
if (isAvatarUrl(assistantAvatar)) {
|
||||
if (authToken?.trim() && assistantAvatar.startsWith("/")) {
|
||||
return html`<img
|
||||
class="chat-avatar ${className} chat-avatar--logo"
|
||||
src="${assistantFallbackAvatar}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
return html`<img
|
||||
class="chat-avatar ${className}"
|
||||
src="${assistantAvatar}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
if (assistantAvatarText) {
|
||||
return html`<div class="chat-avatar ${className}" aria-label="${assistantName}">
|
||||
${assistantAvatarText}
|
||||
</div>`;
|
||||
}
|
||||
return html`<img
|
||||
class="chat-avatar ${className} chat-avatar--logo"
|
||||
src="${assistantFallbackAvatar}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
if (normalized === "assistant") {
|
||||
return html`<img
|
||||
class="chat-avatar ${className} chat-avatar--logo"
|
||||
src="${assistantFallbackAvatar}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
||||
}
|
||||
|
||||
function isAvatarUrl(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed);
|
||||
}
|
||||
|
||||
function resolveRenderableMessageImages(
|
||||
images: ImageBlock[],
|
||||
opts?: ImageRenderOptions,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AppViewState } from "../app-view-state.ts";
|
||||
import {
|
||||
createModelCatalog,
|
||||
@@ -146,18 +146,16 @@ function createChatHeaderState(
|
||||
return { state, request };
|
||||
}
|
||||
|
||||
function flushTasks() {
|
||||
async function flushTasks() {
|
||||
return new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("chat session controls", () => {
|
||||
it("patches the current session model from the chat header picker", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
} satisfies Partial<Response>),
|
||||
);
|
||||
const { state, request } = createChatHeaderState();
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
@@ -170,25 +168,22 @@ describe("chat session controls", () => {
|
||||
|
||||
modelSelect!.value = "openai/gpt-5-mini";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
|
||||
expect(request).toHaveBeenCalledWith("sessions.patch", {
|
||||
key: "main",
|
||||
model: "openai/gpt-5-mini",
|
||||
});
|
||||
expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything());
|
||||
expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini");
|
||||
expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai");
|
||||
vi.unstubAllGlobals();
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini");
|
||||
expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai");
|
||||
},
|
||||
{ interval: 1, timeout: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("reloads effective tools after a chat-header model switch for the active tools panel", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
} satisfies Partial<Response>),
|
||||
);
|
||||
const { state, request } = createChatHeaderState();
|
||||
state.agentsPanel = "tools";
|
||||
state.agentsSelectedId = "main";
|
||||
@@ -217,18 +212,10 @@ describe("chat session controls", () => {
|
||||
},
|
||||
{ interval: 1, timeout: 100 },
|
||||
);
|
||||
|
||||
expect(state.toolsEffectiveResultKey).toBe("main:main:model=openai/gpt-5-mini");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("clears the session model override back to the default model", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
} satisfies Partial<Response>),
|
||||
);
|
||||
const { state, request } = createChatHeaderState({ model: "gpt-5-mini" });
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
@@ -241,14 +228,17 @@ describe("chat session controls", () => {
|
||||
|
||||
modelSelect!.value = "";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
|
||||
expect(request).toHaveBeenCalledWith("sessions.patch", {
|
||||
key: "main",
|
||||
model: null,
|
||||
});
|
||||
expect(state.sessionsResult?.sessions[0]?.model).toBeUndefined();
|
||||
vi.unstubAllGlobals();
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(state.sessionsResult?.sessions[0]?.model).toBeUndefined();
|
||||
},
|
||||
{ interval: 1, timeout: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("disables the chat header model picker while a run is active", () => {
|
||||
@@ -266,12 +256,6 @@ describe("chat session controls", () => {
|
||||
});
|
||||
|
||||
it("keeps the selected model visible when the active session is absent from sessions.list", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
} satisfies Partial<Response>),
|
||||
);
|
||||
const { state } = createChatHeaderState({ omitSessionFromList: true });
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
@@ -290,7 +274,6 @@ describe("chat session controls", () => {
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(rerendered?.value).toBe("openai/gpt-5-mini");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses default thinking options when the active session is absent", () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
resolveChatModelOverrideValue,
|
||||
resolveChatModelSelectState,
|
||||
} from "../chat-model-select-state.ts";
|
||||
import { loadSessions } from "../controllers/sessions.ts";
|
||||
import { pushUniqueTrimmedSelectOption } from "../select-options.ts";
|
||||
import { parseAgentSessionKey } from "../session-key.ts";
|
||||
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts";
|
||||
@@ -71,6 +70,7 @@ export function renderChatSessionSelect(
|
||||
}
|
||||
|
||||
async function refreshSessionOptions(state: AppViewState) {
|
||||
const { loadSessions } = await import("../controllers/sessions.ts");
|
||||
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
|
||||
activeMinutes: 0,
|
||||
limit: 0,
|
||||
|
||||
@@ -13,6 +13,18 @@ vi.mock("../icons.ts", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../tool-display.ts", () => ({
|
||||
formatToolDetail: () => undefined,
|
||||
resolveToolDisplay: ({ name }: { name: string }) => ({
|
||||
name,
|
||||
label: name
|
||||
.split(/[._-]/g)
|
||||
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
|
||||
.join(" "),
|
||||
icon: "zap",
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("tool-cards", () => {
|
||||
it("pretty-prints structured args and pairs tool output onto the same card", () => {
|
||||
const cards = extractToolCards(
|
||||
@@ -187,10 +199,10 @@ describe("tool-cards", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drops tool_card-targeted canvas payloads", () => {
|
||||
const [card] = extractToolCards(
|
||||
it("does not create previews for non-assistant canvas or generic outputs", () => {
|
||||
const cases = [
|
||||
{
|
||||
role: "tool",
|
||||
name: "tool-card target",
|
||||
toolName: "canvas_render",
|
||||
content: JSON.stringify({
|
||||
kind: "canvas",
|
||||
@@ -205,16 +217,8 @@ describe("tool-cards", () => {
|
||||
},
|
||||
}),
|
||||
},
|
||||
"msg:view:2",
|
||||
);
|
||||
|
||||
expect(card?.preview).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not extract inline-html canvas payloads into canvas previews", () => {
|
||||
const [card] = extractToolCards(
|
||||
{
|
||||
role: "tool",
|
||||
name: "inline html",
|
||||
toolName: "canvas_render",
|
||||
content: JSON.stringify({
|
||||
kind: "canvas",
|
||||
@@ -229,36 +233,30 @@ describe("tool-cards", () => {
|
||||
},
|
||||
}),
|
||||
},
|
||||
"msg:view:3",
|
||||
);
|
||||
|
||||
expect(card?.preview).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not create a view preview for malformed json output", () => {
|
||||
const [card] = extractToolCards(
|
||||
{
|
||||
role: "tool",
|
||||
name: "malformed json",
|
||||
toolName: "canvas_render",
|
||||
content: '{"kind":"present_view","view":{"id":"broken"}',
|
||||
},
|
||||
"msg:view:4",
|
||||
);
|
||||
|
||||
expect(card?.preview).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not create a view preview for generic tool text output", () => {
|
||||
const [card] = extractToolCards(
|
||||
{
|
||||
role: "tool",
|
||||
name: "generic text",
|
||||
toolName: "browser.open",
|
||||
content: "present_view: cv_widget",
|
||||
},
|
||||
"msg:view:5",
|
||||
);
|
||||
] as const;
|
||||
|
||||
expect(card?.preview).toBeUndefined();
|
||||
for (const testCase of cases) {
|
||||
const [card] = extractToolCards(
|
||||
{
|
||||
role: "tool",
|
||||
toolName: testCase.toolName,
|
||||
content: testCase.content,
|
||||
},
|
||||
`msg:view:${testCase.name}`,
|
||||
);
|
||||
|
||||
expect(card?.preview, testCase.name).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders expanded cards with inline input and output sections", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildAgentContext,
|
||||
resolveConfiguredCronModelSuggestions,
|
||||
resolveAgentAvatarUrl,
|
||||
resolveAssistantTextAvatar,
|
||||
resolveChatAvatarRenderUrl,
|
||||
resolveEffectiveModelFallbacks,
|
||||
sortLocaleStrings,
|
||||
@@ -122,6 +123,15 @@ describe("assistantAvatarFallbackUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAssistantTextAvatar", () => {
|
||||
it("rejects unsafe invisible controls in assistant text avatars", () => {
|
||||
expect(resolveAssistantTextAvatar("VC")).toBe("VC");
|
||||
expect(resolveAssistantTextAvatar("\u{1F43E}")).toBe("\u{1F43E}");
|
||||
expect(resolveAssistantTextAvatar("V\u202eC")).toBeNull();
|
||||
expect(resolveAssistantTextAvatar("V\u200bC")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentAvatarUrl", () => {
|
||||
it("prefers a runtime avatar URL over non-URL identity avatars", () => {
|
||||
expect(
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderChatQueue } from "../chat/chat-queue.ts";
|
||||
import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts";
|
||||
import { renderWelcomeState } from "../chat/chat-welcome.ts";
|
||||
import type { ChatQueueItem } from "../ui-types.ts";
|
||||
import { cleanupChatModuleState, renderChat, type ChatProps } from "./chat.ts";
|
||||
|
||||
vi.mock("../markdown.ts", () => ({
|
||||
toSanitizedMarkdownHtml: (value: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock("../icons.ts", () => ({
|
||||
icons: new Proxy(
|
||||
@@ -18,85 +16,61 @@ vi.mock("../icons.ts", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../tool-display.ts", () => ({
|
||||
formatToolDetail: () => undefined,
|
||||
resolveToolDisplay: ({ name }: { name: string }) => ({
|
||||
name,
|
||||
label: name,
|
||||
icon: "zap",
|
||||
}),
|
||||
vi.mock("./agents-utils.ts", () => ({
|
||||
agentLogoUrl: () => "/openclaw-logo.svg",
|
||||
assistantAvatarFallbackUrl: () => "apple-touch-icon.png",
|
||||
resolveChatAvatarRenderUrl: (
|
||||
candidate: string | null | undefined,
|
||||
agent: { identity?: { avatar?: string; avatarUrl?: string } },
|
||||
) => {
|
||||
if (typeof candidate === "string" && candidate.startsWith("blob:")) {
|
||||
return candidate;
|
||||
}
|
||||
if (
|
||||
typeof agent.identity?.avatarUrl === "string" &&
|
||||
agent.identity.avatarUrl.startsWith("blob:")
|
||||
) {
|
||||
return agent.identity.avatarUrl;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
resolveAssistantTextAvatar: (value: string | null | undefined) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return value.length <= 3 ? value : null;
|
||||
},
|
||||
}));
|
||||
|
||||
function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
|
||||
return {
|
||||
sessionKey: "agent:main:main",
|
||||
onSessionKeyChange: () => undefined,
|
||||
thinkingLevel: null,
|
||||
showThinking: true,
|
||||
showToolCalls: true,
|
||||
loading: false,
|
||||
sending: false,
|
||||
canAbort: true,
|
||||
messages: [],
|
||||
toolMessages: [],
|
||||
streamSegments: [],
|
||||
stream: "Working...",
|
||||
streamStartedAt: 1,
|
||||
draft: "",
|
||||
queue: [],
|
||||
connected: true,
|
||||
canSend: true,
|
||||
disabledReason: null,
|
||||
error: null,
|
||||
sessions: {
|
||||
ts: 0,
|
||||
path: "",
|
||||
count: 1,
|
||||
defaults: { modelProvider: null, model: null, contextTokens: null },
|
||||
sessions: [{ key: "agent:main:main", kind: "direct", status: "running", updatedAt: null }],
|
||||
},
|
||||
focusMode: false,
|
||||
assistantName: "Test Agent",
|
||||
assistantAvatar: null,
|
||||
onRefresh: () => undefined,
|
||||
onToggleFocusMode: () => undefined,
|
||||
onDraftChange: () => undefined,
|
||||
onSend: () => undefined,
|
||||
onAbort: () => undefined,
|
||||
onQueueRemove: () => undefined,
|
||||
onNewSession: () => undefined,
|
||||
agentsList: { agents: [{ id: "main", name: "Main" }], defaultId: "main" },
|
||||
currentAgentId: "main",
|
||||
onAgentChange: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderQueue(queue: ChatQueueItem[], onQueueSteer = vi.fn()) {
|
||||
function renderQueue(params: {
|
||||
queue: ChatQueueItem[];
|
||||
canAbort?: boolean;
|
||||
onQueueSteer?: (id: string) => void;
|
||||
}) {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
queue,
|
||||
onQueueSteer,
|
||||
}),
|
||||
),
|
||||
renderChatQueue({
|
||||
queue: params.queue,
|
||||
canAbort: params.canAbort ?? true,
|
||||
onQueueSteer: params.onQueueSteer,
|
||||
onQueueRemove: () => undefined,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
return { container, onQueueSteer };
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("chat view queue steering", () => {
|
||||
afterEach(() => {
|
||||
cleanupChatModuleState();
|
||||
});
|
||||
|
||||
describe("chat queue", () => {
|
||||
it("renders Steer only for queued messages during an active run", () => {
|
||||
const { container, onQueueSteer } = renderQueue([
|
||||
{ id: "queued-1", text: "tighten the plan", createdAt: 1 },
|
||||
{ id: "steered-1", text: "already sent", createdAt: 2, kind: "steered" },
|
||||
{ id: "local-1", text: "/status", createdAt: 3, localCommandName: "status" },
|
||||
]);
|
||||
const onQueueSteer = vi.fn();
|
||||
const container = renderQueue({
|
||||
onQueueSteer,
|
||||
queue: [
|
||||
{ id: "queued-1", text: "tighten the plan", createdAt: 1 },
|
||||
{ id: "steered-1", text: "already sent", createdAt: 2, kind: "steered" },
|
||||
{ id: "local-1", text: "/status", createdAt: 3, localCommandName: "status" },
|
||||
],
|
||||
});
|
||||
|
||||
const steerButtons = container.querySelectorAll<HTMLButtonElement>(".chat-queue__steer");
|
||||
expect(steerButtons).toHaveLength(1);
|
||||
@@ -109,131 +83,77 @@ describe("chat view queue steering", () => {
|
||||
});
|
||||
|
||||
it("hides queued-message Steer when no run is active", () => {
|
||||
const { container } = renderQueue(
|
||||
[{ id: "queued-1", text: "tighten the plan", createdAt: 1 }],
|
||||
vi.fn(),
|
||||
);
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
canAbort: false,
|
||||
stream: null,
|
||||
queue: [{ id: "queued-1", text: "tighten the plan", createdAt: 1 }],
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
const container = renderQueue({
|
||||
canAbort: false,
|
||||
onQueueSteer: vi.fn(),
|
||||
queue: [{ id: "queued-1", text: "tighten the plan", createdAt: 1 }],
|
||||
});
|
||||
|
||||
expect(container.querySelector(".chat-queue__steer")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderChat", () => {
|
||||
afterEach(() => {
|
||||
cleanupChatModuleState();
|
||||
});
|
||||
|
||||
describe("chat sidebar raw content", () => {
|
||||
it("keeps markdown raw text toggles idempotent", () => {
|
||||
const container = document.createElement("div");
|
||||
const onOpenSidebar = vi.fn();
|
||||
const rawMarkdown = "```ts\nconst value = 1;\n```";
|
||||
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
sidebarOpen: true,
|
||||
sidebarContent: {
|
||||
kind: "markdown",
|
||||
content: `\`\`\`\n${rawMarkdown}\n\`\`\``,
|
||||
rawText: rawMarkdown,
|
||||
},
|
||||
stream: null,
|
||||
streamStartedAt: null,
|
||||
onCloseSidebar: () => undefined,
|
||||
onOpenSidebar,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const rawButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.includes("View Raw Text"),
|
||||
);
|
||||
rawButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expect(rawButton).not.toBeNull();
|
||||
expect(onOpenSidebar).toHaveBeenCalledWith({
|
||||
expect(
|
||||
buildRawSidebarContent({
|
||||
kind: "markdown",
|
||||
content: `\`\`\`\n${rawMarkdown}\n\`\`\``,
|
||||
rawText: rawMarkdown,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "markdown",
|
||||
content: `\`\`\`\n${rawMarkdown}\n\`\`\``,
|
||||
rawText: rawMarkdown,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("renders configured assistant text avatars in transcript groups", () => {
|
||||
describe("chat welcome", () => {
|
||||
function renderWelcome(params: {
|
||||
assistantAvatar: string | null;
|
||||
assistantAvatarUrl?: string | null;
|
||||
}) {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
assistantName: "Val",
|
||||
assistantAvatar: "VC",
|
||||
assistantAvatarUrl: null,
|
||||
messages: [{ role: "assistant", content: "hello", timestamp: 1000 }],
|
||||
stream: null,
|
||||
streamStartedAt: null,
|
||||
}),
|
||||
),
|
||||
renderWelcomeState({
|
||||
assistantName: "Val",
|
||||
assistantAvatar: params.assistantAvatar,
|
||||
assistantAvatarUrl: params.assistantAvatarUrl,
|
||||
onDraftChange: () => undefined,
|
||||
onSend: () => undefined,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
const avatar = container.querySelector<HTMLElement>(".chat-group.assistant .chat-avatar");
|
||||
it("renders configured assistant text avatars in the welcome state", () => {
|
||||
const container = renderWelcome({ assistantAvatar: "VC", assistantAvatarUrl: null });
|
||||
|
||||
const avatar = container.querySelector<HTMLElement>(".agent-chat__avatar");
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.tagName).toBe("DIV");
|
||||
expect(avatar?.textContent).toContain("VC");
|
||||
expect(avatar?.getAttribute("aria-label")).toBe("Val");
|
||||
});
|
||||
|
||||
it("renders configured assistant image avatars in transcript groups", () => {
|
||||
const container = document.createElement("div");
|
||||
it("renders configured assistant image avatars in the welcome state", () => {
|
||||
const container = renderWelcome({
|
||||
assistantAvatar: "avatars/val.png",
|
||||
assistantAvatarUrl: "blob:identity-avatar",
|
||||
});
|
||||
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
assistantName: "Val",
|
||||
assistantAvatar: "avatars/val.png",
|
||||
assistantAvatarUrl: "blob:identity-avatar",
|
||||
messages: [{ role: "assistant", content: "hello", timestamp: 1000 }],
|
||||
stream: null,
|
||||
streamStartedAt: null,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const avatar = container.querySelector<HTMLImageElement>(
|
||||
".chat-group.assistant img.chat-avatar",
|
||||
);
|
||||
const avatar = container.querySelector<HTMLImageElement>("img");
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.getAttribute("src")).toBe("blob:identity-avatar");
|
||||
expect(avatar?.getAttribute("alt")).toBe("Val");
|
||||
});
|
||||
|
||||
it("uses the Molty png as the welcome fallback assistant avatar", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
assistantName: "Val",
|
||||
assistantAvatar: null,
|
||||
assistantAvatarUrl: null,
|
||||
messages: [],
|
||||
stream: null,
|
||||
streamStartedAt: null,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
const container = renderWelcome({ assistantAvatar: null, assistantAvatarUrl: null });
|
||||
|
||||
const avatar = container.querySelector<HTMLImageElement>(".agent-chat__avatar--logo img");
|
||||
expect(avatar).not.toBeNull();
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
isSupportedChatAttachmentMimeType,
|
||||
} from "../chat/attachment-support.ts";
|
||||
import { buildChatItems } from "../chat/build-chat-items.ts";
|
||||
import { renderChatQueue } from "../chat/chat-queue.ts";
|
||||
import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts";
|
||||
import { renderWelcomeState, resolveAssistantDisplayAvatar } from "../chat/chat-welcome.ts";
|
||||
import { renderContextNotice } from "../chat/context-notice.ts";
|
||||
import { DeletedMessages } from "../chat/deleted-messages.ts";
|
||||
import { exportChatMarkdown } from "../chat/export.ts";
|
||||
@@ -14,7 +17,6 @@ import {
|
||||
renderMessageGroup,
|
||||
renderReadingIndicatorGroup,
|
||||
renderStreamingGroup,
|
||||
resolveAssistantTextAvatar,
|
||||
} from "../chat/grouped-render.ts";
|
||||
import { InputHistory } from "../chat/input-history.ts";
|
||||
import { PinnedMessages } from "../chat/pinned-messages.ts";
|
||||
@@ -34,7 +36,6 @@ import {
|
||||
} from "../chat/slash-commands.ts";
|
||||
import { isSttSupported, startStt, stopStt } from "../chat/speech.ts";
|
||||
import { renderCompactionIndicator, renderFallbackIndicator } from "../chat/status-indicators.ts";
|
||||
import { buildSidebarContent } from "../chat/tool-cards.ts";
|
||||
import { getExpandedToolCards, syncToolCardExpansionState } from "../chat/tool-expansion-state.ts";
|
||||
import type { EmbedSandboxMode } from "../embed-sandbox.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
@@ -43,11 +44,6 @@ import { detectTextDirection } from "../text-direction.ts";
|
||||
import type { SessionsListResult } from "../types.ts";
|
||||
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
|
||||
import { resolveLocalUserName } from "../user-identity.ts";
|
||||
import {
|
||||
agentLogoUrl,
|
||||
assistantAvatarFallbackUrl,
|
||||
resolveChatAvatarRenderUrl,
|
||||
} from "./agents-utils.ts";
|
||||
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
|
||||
import "../components/resizable-divider.ts";
|
||||
|
||||
@@ -145,11 +141,6 @@ function getPinnedMessages(sessionKey: string): PinnedMessages {
|
||||
);
|
||||
}
|
||||
|
||||
function toPlainTextCodeFence(value: string, language = ""): string {
|
||||
const fenceHeader = language ? `\`\`\`${language}` : "```";
|
||||
return `${fenceHeader}\n${value}\n\`\`\``;
|
||||
}
|
||||
|
||||
function getDeletedMessages(sessionKey: string): DeletedMessages {
|
||||
return getOrCreateSessionCacheValue(
|
||||
deletedMessagesMap,
|
||||
@@ -477,78 +468,6 @@ function exportMarkdown(props: ChatProps): void {
|
||||
exportChatMarkdown(props.messages, props.assistantName);
|
||||
}
|
||||
|
||||
const WELCOME_SUGGESTIONS = [
|
||||
"What can you do?",
|
||||
"Summarize my recent sessions",
|
||||
"Help me configure a channel",
|
||||
"Check system health",
|
||||
];
|
||||
|
||||
function renderWelcomeState(props: ChatProps): TemplateResult {
|
||||
const name = props.assistantName || "Assistant";
|
||||
const avatar = resolveAssistantAvatarUrl(props);
|
||||
const avatarText = avatar ? null : resolveAssistantTextAvatar(props.assistantAvatar);
|
||||
const fallbackAvatarUrl = assistantAvatarFallbackUrl(props.basePath ?? "");
|
||||
const logoUrl = agentLogoUrl(props.basePath ?? "");
|
||||
|
||||
return html`
|
||||
<div class="agent-chat__welcome" style="--agent-color: var(--accent)">
|
||||
<div class="agent-chat__welcome-glow"></div>
|
||||
${avatar
|
||||
? html`<img
|
||||
src=${avatar}
|
||||
alt=${name}
|
||||
style="width:56px; height:56px; border-radius:50%; object-fit:cover;"
|
||||
/>`
|
||||
: avatarText
|
||||
? html`<div class="agent-chat__avatar agent-chat__avatar--text" aria-label=${name}>
|
||||
${avatarText}
|
||||
</div>`
|
||||
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
|
||||
<img src=${fallbackAvatarUrl} alt=${name} />
|
||||
</div>`}
|
||||
<h2>${name}</h2>
|
||||
<div class="agent-chat__badges">
|
||||
<span class="agent-chat__badge"><img src=${logoUrl} alt="" /> Ready to chat</span>
|
||||
</div>
|
||||
<p class="agent-chat__hint">Type a message below · <kbd>/</kbd> for commands</p>
|
||||
<div class="agent-chat__suggestions">
|
||||
${WELCOME_SUGGESTIONS.map(
|
||||
(text) => html`
|
||||
<button
|
||||
type="button"
|
||||
class="agent-chat__suggestion"
|
||||
@click=${() => {
|
||||
props.onDraftChange(text);
|
||||
props.onSend();
|
||||
}}
|
||||
>
|
||||
${text}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function resolveAssistantAvatarUrl(
|
||||
props: Pick<ChatProps, "assistantAvatar" | "assistantAvatarUrl">,
|
||||
): string | null {
|
||||
return resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
|
||||
identity: {
|
||||
avatar: props.assistantAvatar ?? undefined,
|
||||
avatarUrl: props.assistantAvatarUrl ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAssistantDisplayAvatar(
|
||||
props: Pick<ChatProps, "assistantAvatar" | "assistantAvatarUrl">,
|
||||
): string | null {
|
||||
return resolveAssistantAvatarUrl(props) ?? resolveAssistantTextAvatar(props.assistantAvatar);
|
||||
}
|
||||
|
||||
function renderSearchBar(requestUpdate: () => void): TemplateResult | typeof nothing {
|
||||
if (!vs.searchOpen) {
|
||||
return nothing;
|
||||
@@ -1135,22 +1054,12 @@ export function renderChat(props: ChatProps) {
|
||||
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false,
|
||||
onClose: props.onCloseSidebar!,
|
||||
onViewRawText: () => {
|
||||
if (!props.sidebarContent || !props.onOpenSidebar) {
|
||||
if (!props.onOpenSidebar) {
|
||||
return;
|
||||
}
|
||||
if (props.sidebarContent.kind === "markdown") {
|
||||
const rawText = props.sidebarContent.rawText ?? props.sidebarContent.content;
|
||||
props.onOpenSidebar(
|
||||
buildSidebarContent(toPlainTextCodeFence(rawText), { rawText }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (props.sidebarContent.rawText?.trim()) {
|
||||
props.onOpenSidebar(
|
||||
buildSidebarContent(
|
||||
toPlainTextCodeFence(props.sidebarContent.rawText, "json"),
|
||||
),
|
||||
);
|
||||
const rawContent = buildRawSidebarContent(props.sidebarContent);
|
||||
if (rawContent) {
|
||||
props.onOpenSidebar(rawContent);
|
||||
}
|
||||
},
|
||||
})}
|
||||
@@ -1159,61 +1068,12 @@ export function renderChat(props: ChatProps) {
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
${props.queue.length
|
||||
? html`
|
||||
<div class="chat-queue" role="status" aria-live="polite">
|
||||
<div class="chat-queue__title">Queued (${props.queue.length})</div>
|
||||
<div class="chat-queue__list">
|
||||
${props.queue.map(
|
||||
(item) => html`
|
||||
<div
|
||||
class="chat-queue__item ${item.kind === "steered"
|
||||
? "chat-queue__item--steered"
|
||||
: ""}"
|
||||
>
|
||||
<div class="chat-queue__main">
|
||||
${item.kind === "steered"
|
||||
? html`<span class="chat-queue__badge">Steered</span>`
|
||||
: nothing}
|
||||
<div class="chat-queue__text">
|
||||
${item.text ||
|
||||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-queue__actions">
|
||||
${props.canAbort &&
|
||||
props.onQueueSteer &&
|
||||
item.kind !== "steered" &&
|
||||
!item.localCommandName
|
||||
? html`
|
||||
<button
|
||||
class="btn chat-queue__steer"
|
||||
type="button"
|
||||
title="Steer now"
|
||||
aria-label="Steer queued message"
|
||||
@click=${() => props.onQueueSteer?.(item.id)}
|
||||
>
|
||||
${icons.cornerDownRight}
|
||||
<span>Steer</span>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
<button
|
||||
class="btn chat-queue__remove"
|
||||
type="button"
|
||||
aria-label="Remove queued message"
|
||||
@click=${() => props.onQueueRemove(item.id)}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${renderChatQueue({
|
||||
queue: props.queue,
|
||||
canAbort: props.canAbort,
|
||||
onQueueSteer: props.onQueueSteer,
|
||||
onQueueRemove: props.onQueueRemove,
|
||||
})}
|
||||
${renderSideResult(props.sideResult, props.onDismissSideResult)}
|
||||
${renderFallbackIndicator(props.fallbackStatus)}
|
||||
${renderCompactionIndicator(props.compactionStatus)}
|
||||
|
||||
Reference in New Issue
Block a user