From b9f18be67393928a41a3aa03a336db6bd78908e4 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Fri, 17 Jul 2026 22:57:05 -0400 Subject: [PATCH] SMOODEV-2668: Inline suggested-reply chips under the latest reply; dismiss on tap The chips lived in a fixed slot above the composer, visually detached from the reply they belonged to, and only cleared on the next render. Render them in the message flow directly after the last assistant bubble, remove the strip the moment a chip is tapped, and keep them purely optional. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Tz7j9eqPBBL36CEFZpjoWz --- .changeset/chips-inline-ux.md | 5 +++++ src/element.test.ts | 20 ++++++++++++++++++ src/element.ts | 38 +++++++++++++++++++---------------- src/styles.ts | 2 -- 4 files changed, 46 insertions(+), 19 deletions(-) create mode 100644 .changeset/chips-inline-ux.md 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; }