Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 66 additions & 20 deletions packages/ui/src/chat-ui/ButtonsMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { LitElement, html, css, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { marked } from "marked";
import { MessageTypeRegistry, type MessageSender, type MessageAction } from "@chativa/core";
import { MessageTypeRegistry, chatStore, type MessageSender, type MessageAction } from "@chativa/core";

/** Strip the wrapping <p>…</p> that marked adds for inline content. */
function renderInlineMarkdown(text: string): ReturnType<typeof unsafeHTML> {
Expand Down Expand Up @@ -60,6 +60,13 @@ export class ButtonsMessage extends LitElement {

.avatar.hidden { visibility: hidden; }
.avatar svg { width: 16px; height: 16px; color: #7c3aed; }
.avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
display: block;
}

.content {
display: flex;
Expand Down Expand Up @@ -104,6 +111,19 @@ export class ButtonsMessage extends LitElement {
color: #ffffff;
}

/* Markdown list labels (e.g. 1. Option) should not add browser default indent. */
.action-btn ol,
.action-btn ul {
margin: 0;
padding: 0;
list-style-position: inside;
}

.action-btn li {
margin: 0;
padding: 0;
}

.action-btn:active:not(:disabled) {
opacity: 0.85;
}
Expand All @@ -121,11 +141,24 @@ export class ButtonsMessage extends LitElement {
}

.selected-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
color: #64748b;
padding: 2px 4px;
}

.selected-label ol,
.selected-label ul,
.selected-label li,
.selected-value {
display: inline;
margin: 0;
padding: 0;
list-style-position: inside;
}

.time {
font-size: 0.6875rem;
color: #94a3b8;
Expand Down Expand Up @@ -180,38 +213,51 @@ export class ButtonsMessage extends LitElement {
);
}

private _renderBotAvatar(avatarUrl?: string) {
return html`
<div class="avatar ${this.hideAvatar ? "hidden" : ""}">
${avatarUrl
? html`<img src=${avatarUrl} alt="bot avatar" />`
: html`
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="8" width="14" height="12" rx="2.5" />
<circle cx="9.5" cy="13" r="1.5" fill="white" />
<circle cx="14.5" cy="13" r="1.5" fill="white" />
<path
d="M9.5 17c.5.5 1.4.8 2.5.8s2-.3 2.5-.8"
stroke="white"
stroke-width="1.2"
stroke-linecap="round"
fill="none"
/>
</svg>
`}
</div>
`;
}

render() {
const isUser = this.sender === "user";
const text = this.messageData?.text ? String(this.messageData.text) : null;
const buttons = (this.messageData?.buttons ?? []) as MessageAction[];
const persistent = this._persistent;
const avatarCfg = chatStore.getState().theme.avatar;
const showBotAvatar = avatarCfg?.showBot !== false;

return html`
<div class="message ${isUser ? "user" : "bot"}">
${!isUser
? html`
<div class="avatar ${this.hideAvatar ? "hidden" : ""}">
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="8" width="14" height="12" rx="2.5" />
<circle cx="9.5" cy="13" r="1.5" fill="white" />
<circle cx="14.5" cy="13" r="1.5" fill="white" />
<path
d="M9.5 17c.5.5 1.4.8 2.5.8s2-.3 2.5-.8"
stroke="white"
stroke-width="1.2"
stroke-linecap="round"
fill="none"
/>
</svg>
</div>
`
: nothing}
${!isUser && showBotAvatar ? this._renderBotAvatar(avatarCfg?.bot) : nothing}
<div class="content">
${text ? html`<div class="bubble">${text}</div>` : nothing}

${!persistent && this._selected !== null
/* One-time mode: replace buttons with confirmation label */
? html`<span class="selected-label">✓ ${renderInlineMarkdown(this._selected!)}</span>`
? html`
<span class="selected-label">
<span aria-hidden="true">✓</span>
<span class="selected-value">${renderInlineMarkdown(this._selected!)}</span>
</span>
`
/* Persistent mode or pre-selection: show full button list */
: html`
<div class="btn-list">
Expand Down
53 changes: 34 additions & 19 deletions packages/ui/src/chat-ui/QuickReplyMessage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { LitElement, html, css, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { MessageTypeRegistry, type MessageSender, type MessageAction } from "@chativa/core";
import { MessageTypeRegistry, chatStore, type MessageSender, type MessageAction } from "@chativa/core";

/**
* Quick-reply message component.
Expand Down Expand Up @@ -43,6 +43,13 @@ export class QuickReplyMessage extends LitElement {
.avatar.hidden { visibility: hidden; }

.avatar svg { width: 16px; height: 16px; color: #7c3aed; }
.avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
display: block;
}

.content {
display: flex;
Expand Down Expand Up @@ -174,33 +181,41 @@ export class QuickReplyMessage extends LitElement {
);
}

private _renderBotAvatar(avatarUrl?: string) {
return html`
<div class="avatar ${this.hideAvatar ? "hidden" : ""}">
${avatarUrl
? html`<img src=${avatarUrl} alt="bot avatar" />`
: html`
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="8" width="14" height="12" rx="2.5" />
<circle cx="9.5" cy="13" r="1.5" fill="white" />
<circle cx="14.5" cy="13" r="1.5" fill="white" />
<path
d="M9.5 17c.5.5 1.4.8 2.5.8s2-.3 2.5-.8"
stroke="white"
stroke-width="1.2"
stroke-linecap="round"
fill="none"
/>
</svg>
`}
</div>
`;
}

render() {
const isUser = this.sender === "user";
const actions = this.messageData?.actions as MessageAction[] | undefined;
const keepActions = Boolean(this.messageData?.keepActions);
const showChips =
actions && actions.length > 0 && (!this._used || (keepActions && this._selectedValue !== null));
const avatarCfg = chatStore.getState().theme.avatar;
const showBotAvatar = avatarCfg?.showBot !== false;

return html`
<div class="message ${isUser ? "user" : "bot"}">
${!isUser
? html`
<div class="avatar ${this.hideAvatar ? "hidden" : ""}">
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="8" width="14" height="12" rx="2.5" />
<circle cx="9.5" cy="13" r="1.5" fill="white" />
<circle cx="14.5" cy="13" r="1.5" fill="white" />
<path
d="M9.5 17c.5.5 1.4.8 2.5.8s2-.3 2.5-.8"
stroke="white"
stroke-width="1.2"
stroke-linecap="round"
fill="none"
/>
</svg>
</div>
`
: nothing}
${!isUser && showBotAvatar ? this._renderBotAvatar(avatarCfg?.bot) : nothing}
<div class="content">
${this.messageData?.text
? html`<div class="bubble">${this.messageData.text as string}</div>`
Expand Down
65 changes: 64 additions & 1 deletion packages/ui/src/chat-ui/__tests__/ButtonsMessage.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { describe, it, expect, vi } from "vitest";
import { marked } from "marked";

const { getStateMock, storeState } = vi.hoisted(() => {
const storeState: { theme: { avatar?: { bot?: string; showBot?: boolean } } } = {
theme: {},
};
return {
getStateMock: vi.fn(() => storeState),
storeState,
};
});

vi.mock("@chativa/core", () => ({
MessageTypeRegistry: { register: vi.fn() },
chatStore: { getState: () => ({ theme: {} }) },
chatStore: { getState: getStateMock },
}));
vi.mock("lit", () => ({
LitElement: class {},
Expand All @@ -20,6 +30,8 @@ vi.mock("lit/directives/unsafe-html.js", () => ({
unsafeHTML: (html: string) => html,
}));

import { ButtonsMessage } from "../ButtonsMessage";

/** Mirrors the renderInlineMarkdown helper in ButtonsMessage.ts */
function renderInlineMarkdown(text: string): string {
return (marked.parse(text, { async: false }) as string).replace(/^<p>(.*)<\/p>\n?$/s, "$1");
Expand All @@ -43,3 +55,54 @@ describe("ButtonsMessage — label markdown rendering", () => {
expect(result.startsWith("<p>")).toBe(false);
});
});

function collectTemplateValues(node: unknown, out: string[] = []): string[] {
if (typeof node === "string") {
out.push(node);
return out;
}
if (!node || typeof node !== "object") return out;
if (Array.isArray(node)) {
node.forEach((n) => collectTemplateValues(n, out));
return out;
}

const maybeTemplate = node as { vals?: unknown[] };
if (Array.isArray(maybeTemplate.vals)) {
collectTemplateValues(maybeTemplate.vals, out);
}

Object.values(node).forEach((value) => collectTemplateValues(value, out));
return out;
}

describe("ButtonsMessage — avatar theming", () => {
it("uses configured bot avatar URL", () => {
storeState.theme.avatar = { bot: "https://cdn.example.com/bot.png" };

const message = new ButtonsMessage();
message.sender = "bot";
message.messageData = { text: "Hi", buttons: [{ label: "A" }] };

const rendered = message.render();
const values = collectTemplateValues(rendered);

expect(values).toContain("https://cdn.example.com/bot.png");
});

it("hides bot avatar when showBot is false", () => {
storeState.theme.avatar = {
bot: "https://cdn.example.com/bot.png",
showBot: false,
};

const message = new ButtonsMessage();
message.sender = "bot";
message.messageData = { text: "Hi", buttons: [{ label: "A" }] };

const rendered = message.render();
const values = collectTemplateValues(rendered);

expect(values).not.toContain("https://cdn.example.com/bot.png");
});
});
86 changes: 86 additions & 0 deletions packages/ui/src/chat-ui/__tests__/QuickReplyMessage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from "vitest";

const { getStateMock, storeState } = vi.hoisted(() => {
const storeState: { theme: { avatar?: { bot?: string; showBot?: boolean } } } = {
theme: {},
};
return {
getStateMock: vi.fn(() => storeState),
storeState,
};
});

vi.mock("@chativa/core", () => ({
MessageTypeRegistry: { register: vi.fn() },
chatStore: { getState: getStateMock },
}));
vi.mock("lit", () => ({
LitElement: class {},
html: (strings: TemplateStringsArray, ...vals: unknown[]) => ({ strings, vals }),
css: (strings: TemplateStringsArray, ...vals: unknown[]) => ({ strings, vals }),
nothing: null,
}));
vi.mock("lit/decorators.js", () => ({
customElement: () => () => {},
property: () => () => {},
state: () => () => {},
}));

import { QuickReplyMessage } from "../QuickReplyMessage";

function collectTemplateValues(node: unknown, out: string[] = []): string[] {
if (typeof node === "string") {
out.push(node);
return out;
}
if (!node || typeof node !== "object") return out;
if (Array.isArray(node)) {
node.forEach((n) => collectTemplateValues(n, out));
return out;
}

const maybeTemplate = node as { vals?: unknown[] };
if (Array.isArray(maybeTemplate.vals)) {
collectTemplateValues(maybeTemplate.vals, out);
}

Object.values(node).forEach((value) => collectTemplateValues(value, out));
return out;
}

describe("QuickReplyMessage — avatar theming", () => {
it("uses configured bot avatar URL", () => {
storeState.theme.avatar = { bot: "https://cdn.example.com/bot-quick.png" };

const message = new QuickReplyMessage();
message.sender = "bot";
message.messageData = {
text: "Choose",
actions: [{ label: "Yes", value: "yes" }],
};

const rendered = message.render();
const values = collectTemplateValues(rendered);

expect(values).toContain("https://cdn.example.com/bot-quick.png");
});

it("hides bot avatar when showBot is false", () => {
storeState.theme.avatar = {
bot: "https://cdn.example.com/bot-quick.png",
showBot: false,
};

const message = new QuickReplyMessage();
message.sender = "bot";
message.messageData = {
text: "Choose",
actions: [{ label: "Yes", value: "yes" }],
};

const rendered = message.render();
const values = collectTemplateValues(rendered);

expect(values).not.toContain("https://cdn.example.com/bot-quick.png");
});
});
Loading