diff --git a/.changeset/chips-inline-ux.md b/.changeset/chips-inline-ux.md new file mode 100644 index 0000000..1cd80f1 --- /dev/null +++ b/.changeset/chips-inline-ux.md @@ -0,0 +1,5 @@ +--- +'@smooai/chat-widget': patch +--- + +Suggested-reply chips now render inline in the message flow, directly under the latest assistant reply (previously a fixed slot above the composer, visually detached), and the strip is removed the instant a chip is tapped. Chips remain optional shortcuts — the composer stays live. diff --git a/src/element.test.ts b/src/element.test.ts index 24c4cfa..ac31c21 100644 --- a/src/element.test.ts +++ b/src/element.test.ts @@ -327,6 +327,26 @@ describe('mid-conversation suggested-reply chips', () => { expect(chips[1]?.textContent).toBe('See pricing'); }); + it('renders chips inline in the message flow, directly after the last bubble (SMOODEV-2668)', () => { + const { sr, api } = mountAnon(); + api.messages = [user('u1', 'hi'), finalized('a1', 'Hello!', ['Book a demo'])]; + api.renderMessages(); + const messages = sr.querySelector('.messages')!; + const strip = messages.querySelector(':scope > .reply-suggestions'); + expect(strip).not.toBeNull(); + // The strip is the LAST child of the message flow — right under the reply. + expect(messages.lastElementChild).toBe(strip); + }); + + it('tapping a chip removes the strip immediately (before any new turn renders)', () => { + const { sr, api } = mountAnon(); + api.controller = { send: () => {}, disconnect: () => {} }; + api.messages = [user('u1', 'hi'), finalized('a1', 'Hello!', ['Book a demo'])]; + api.renderMessages(); + (sr.querySelector('.reply-suggestions .chip') as HTMLButtonElement).click(); + expect(sr.querySelectorAll('.reply-suggestions .chip').length).toBe(0); + }); + it('only shows suggestions for the LATEST message (not an earlier assistant turn)', () => { const { sr, api } = mountAnon(); // Assistant turn with suggestions is followed by a newer user message. diff --git a/src/element.ts b/src/element.ts index 87aba7a..3ce271a 100644 --- a/src/element.ts +++ b/src/element.ts @@ -294,7 +294,6 @@ export class SmoothAgentChatElement extends HTMLElement { private inputEl: HTMLTextAreaElement | null = null; private sendBtn: HTMLButtonElement | null = null; private micBtn: HTMLButtonElement | null = null; - private suggestionsEl: HTMLElement | null = null; // ── Smooth streaming reveal ── // Tokens arrive in variable-size bursts at uneven rates, so revealing text in @@ -544,7 +543,6 @@ export class SmoothAgentChatElement extends HTMLElement { : ''; const chatHtml = `
-
@@ -586,7 +584,6 @@ export class SmoothAgentChatElement extends HTMLElement { this.sendBtn = container.querySelector('.send'); this.micBtn = container.querySelector('.mic'); this.interruptEl = container.querySelector('.interrupt'); - this.suggestionsEl = container.querySelector('.reply-suggestions'); this.launcherEl?.addEventListener('click', () => this.openChat()); container.querySelector('.close')?.addEventListener('click', () => this.closeChat()); @@ -1204,23 +1201,27 @@ export class SmoothAgentChatElement extends HTMLElement { this.messagesEl.appendChild(this.renderSources(msg.citations)); } } - this.scrollToBottom(true); this.renderSuggestions(); + this.scrollToBottom(true); } /** - * Render (or clear) the mid-conversation suggested-reply chips under the - * composer. Shown ONLY when: the feature is enabled, no interrupt / restore - * overlay is active (those reuse the slot above the composer), and the LATEST - * message is a finalized assistant turn that carried suggestions. A new turn - * (agent or the visitor typing) appends messages, so the latest is no longer - * that assistant message and the chips clear on the next render. Chips reuse - * the empty-state `.prompts`/`.chip` styling and send via the same path. + * Render the mid-conversation suggested-reply chips INLINE in the message + * flow, directly under the latest bubble (SMOODEV-2668 — they used to live + * in a fixed slot above the composer, visually detached from the reply). + * Shown ONLY when: the feature is enabled, no interrupt / restore overlay is + * active, and the LATEST message is a finalized assistant turn that carried + * suggestions. Chips are shortcuts, never a gate — the composer stays live. + * Picking one removes the strip immediately; any new turn rebuilds the + * message list, so stale chips can't linger. Chips reuse the empty-state + * `.prompts`/`.chip` styling and send via the same path. */ private renderSuggestions(): void { - const el = this.suggestionsEl; - if (!el) return; - el.replaceChildren(); + const host = this.messagesEl; + if (!host) return; + // Self-clearing: drop any strip from a previous render so a standalone + // call (e.g. an interrupt overlay appearing) can only ever hide chips. + host.querySelector(':scope > .reply-suggestions')?.remove(); if (!this.showSuggestedReplies) return; // Hidden while an OTP / tool-confirmation / restore overlay is active. if (this.interrupt || this.identityRestore.phase !== 'idle') return; @@ -1228,16 +1229,19 @@ export class SmoothAgentChatElement extends HTMLElement { if (!last || last.role !== 'assistant' || last.streaming || !last.suggestions || last.suggestions.length === 0) return; const chips = document.createElement('div'); - chips.className = 'prompts'; + chips.className = 'prompts reply-suggestions'; for (const suggestion of last.suggestions) { const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'chip'; chip.textContent = suggestion; - chip.addEventListener('click', () => this.submitPrompt(suggestion)); + chip.addEventListener('click', () => { + chips.remove(); // gone the moment a choice is made + this.submitPrompt(suggestion); + }); chips.appendChild(chip); } - el.appendChild(chips); + host.appendChild(chips); } // ─────────────────────── Smooth streaming reveal ─────────────────────────── diff --git a/src/styles.ts b/src/styles.ts index 2afa2a5..b539687 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -697,8 +697,6 @@ ${ } /* ───────────── Mid-conversation suggested-reply chips ─────────────── */ -.reply-suggestions { padding: 0 14px 4px; } -.reply-suggestions:empty { display: none; } /* ─────────────── OTP / tool-confirmation interrupt ────────────────── */ .interrupt { padding: 0 14px; }