Skip to content

@W-24333476 - Add opener question pills (pdp-questions attribute) - #14

Merged
sf-samarth-urs merged 10 commits into
SalesforceCommerceCloud:mainfrom
sf-samarth-urs:feature/pdp-opener-pills
Oct 5, 2026
Merged

sf-samarth-urs merged 10 commits into
SalesforceCommerceCloud:mainfrom
sf-samarth-urs:feature/pdp-opener-pills

Conversation

@sf-samarth-urs

@sf-samarth-urs sf-samarth-urs commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds an opener question pills UI to the inline agent widget, driven by a new pdp-questions attribute on the custom element. On a PDP where the merchant has published questions (via Content Studio → SCAPI c_pdpQuestions), the widget renders up to 5 clickable pills, aligned to the Shiseido-Sandbox north-star design.

North-star layout

  • Order: InputBar → Response → QuestionPills. The pill shelf lives BELOW the answer.
  • InputBar placeholder templates the current product name: "Ask me anything about <productName>" (via a new product-name attribute).
  • Right-aligned dark pill-shaped user bubble above the answer replaces the previous highlighted-pill + echoed-query treatment.
  • Pill shelf drains: clicked pills are hidden (askedQuestions filter); when empty the shelf disappears and the InputBar carries the shopper forward.
  • SPA PDP → PDP: RESET_CONVERSATION clears askedQuestions; the new product's full shelf is shown.

Welcome-handoff state machine

Agentforce auto-emits a "welcome" greeting before the shopper sends anything; the widget must drop it so an idle PDP shows no unsolicited bot text. The handoff spans several timing windows (fast agent, slow agent, gate-timeout) and misidentifying the welcome leaks it as the answer OR drops the real answer as "the welcome". The rules are extracted into a pure classifier module (src/provider/welcome-tracker.ts) covered by 12 unit tests — one per window, two named for regressions caught during verification. WidgetProvider.tsx is a thin dispatcher over the classifier verdict. Separately, recoverable SDK reconnect errors no longer reach SET_ERROR (which was clearing lastQuestion+answer and causing the red "Something went wrong" flash).

Contract

  • pdp-questions — JSON-encoded string array, e.g. '["Q1?","Q2?"]'. Parser trims, drops empties, caps at 5 (MAX_PDP_QUESTIONS); invalid input → empty list.
  • product-name — plain string; absent → generic fallback.

Companion PR in pwa-kit: @W-24333476 - Add opener question pills to inline PDP widget (plumbs c_pdpQuestions + name through the PDP shim into these attributes).

Changes

  • src/provider/pdp-questions.ts — parsePdpQuestions + MAX_PDP_QUESTIONS = 5
  • src/provider/welcome-gate.ts — awaitable WelcomeGate state machine (send-side guard)
  • src/provider/welcome-tracker.ts — pure classifier for inbound Agentforce events (classifyMessage, shouldAppendStreamingToken)
  • src/provider/types.ts — productName?, pdpQuestions? on WidgetConfig; pdpQuestions, askedQuestions on WidgetState; SET_PDP_QUESTIONS action
  • src/provider/reducer.ts — ASK_QUESTION dedup-appends to askedQuestions, RESET_CONVERSATION clears it, SET_PDP_QUESTIONS case
  • src/provider/WidgetProvider.tsx — gate wiring + welcomeDroppedRef; message / streaming_token dispatch off classifier verdicts; error surfaces only non-recoverable errors; SET_AGENT_TYPING on send intent
  • src/custom-element.ts — observes pdp-questions + product-name
  • src/components/InputBar.tsx — placeholder precedence: config.placeholder > product-name template > generic
  • src/components/QuestionPills.tsx — filters askedQuestions; no selection/highlight
  • src/components/Response.tsx — right-aligned user bubble markup
  • src/App.tsx — layout: InputBar → Response → QuestionPills
  • src/styles.css — .user-bubble + .user-bubble-row; drops .pill[aria-pressed] selected state; --iaw-user-bubble-* theme vars
  • Tests: pdp-questions.test.ts (9), welcome-gate.test.ts (12), welcome-tracker.test.ts (12), reducer.test.ts updates

Test plan

  • npm test — 192 tests pass
  • npm run build — clean build; UMD 346 KB / 108 KB gzip
  • Manual — Pills render BELOW the answer; placeholder reads "Ask me anything about <name>"
  • Manual — Clicked pill disappears; remaining pills stay; empty shelf → disappears
  • Manual — First pill click answers (previously swallowed as the welcome)
  • Manual — No welcome flash, no red-flash, no answer-swallow on cold incognito load (both with and without DevTools "Disable cache")
  • Manual — Typing dots appear immediately on send intent
  • Manual — SPA-navigate between PDPs: pills re-seed, placeholder re-templates, askedQuestions clears
  • Manual — Malformed / oversized pdp-questions: safe fallback

Follow-ups (not in this PR)

  • Dynamic follow-up pills (Shilpi) — regenerate shelf from latest answer + pageContext
  • Pre-warm SCRT2 handshake on PDP mount (bounded by org SCRT2 concurrent-session cap; needs a GUS WI)
  • Analytics event inline_pdp_pill_click
  • Mobile layout polish

@chintandesai-cimulate chintandesai-cimulate left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall LGTM, tests are missing for other classes, can we add them

@shilpi1998 shilpi1998 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Render a pill row above the input on PDPs when the host provides a JSON-
encoded array of opener questions via the new `pdp-questions` attribute
(SCAPI `c_pdpQuestions` is the intended source). Tapping a pill routes
through the same `sendMessage` path as typed input, so product-context
prefixing, readiness gating, and welcome suppression all apply uniformly.

Pills auto-hide once the shopper has engaged (asked a question or seen
an error) and re-appear on SPA-navigation between PDPs via the existing
`RESET_CONVERSATION` path, when the host swaps the list.

- `parsePdpQuestions` — defensive JSON parse, non-array/non-string reject,
  trim, dedupe, cap at MAX_PDP_QUESTIONS (5). Never throws.
- `<QuestionPills>` — accessible list-of-buttons, `prepareConnection` on
  hover/focus to hide handshake latency behind pill intent.
- New CSS class + 4 themable `--iaw-pill-*` custom properties.
- 9 new unit tests, all 142 existing tests still green, clean typecheck,
  UMD + ESM bundles build.
Hover is a bystander signal: a shopper scrolling past the pills has no
intent to use Ask Assistant, but prepareConnection on mouseEnter would
still spin up a SCRT2 session that sits unused against the ~11k
concurrent session cap. Rely on sendMessage's own ensureConnected guard
so the first click does the handshake inline, same as InputBar on a
cold start.
Addresses Chintan's PR review: new/changed reducer branches for the
opener-pills work had no direct coverage. Adds 20 cases hitting:
  - SET_PDP_QUESTIONS (new) — replaces list, accepts [], no cross-field
    bleed into conversation state
  - RESET_CONVERSATION (modified) — preserves pdpQuestions so SPA
    PDP→PDP nav doesn't flicker the pills, while clearing conversation
    state and leaving status/connectionReady alone
  - ASK_QUESTION — pill-click doesn't wipe sibling pills
  - SET_CONNECTION_READY — gates the pill `disabled` binding
  - SET_ERROR — clears conversation on error, preserves on error:null
  - plus smoke for SET_STATUS, APPEND_STREAMING_TOKEN, SET_ANSWER,
    SET_AGENT_TYPING and the unknown-action default branch
Match the Alexa for shopping / Shiseido-Sandbox north-star interaction
model: the pre-generated opener-question menu is a persistent surface
so the shopper can ask one question, read the answer, then ask another
from the same list without having to retype anything or navigate away.

Previously the pills hid entirely once the shopper engaged, which
forced them to type manually or SPA-navigate away and back to see the
remaining questions.

- Drop the `hasEngaged` gate — pills stay rendered as long as the
  product has a non-empty pdp-questions list.
- Disable all pills while a question is in-flight (agentTyping or
  streamingText present) so the shopper can't stack sends; re-enables
  on SET_ANSWER / SET_ERROR.
- Mark the clicked pill with `aria-pressed` and style it in the
  inverted accent color so the shopper can see which pill produced
  the answer below. Selection is derived from state.lastQuestion so
  it clears naturally on RESET_CONVERSATION (SPA PDP→PDP nav) and
  stays empty when the shopper free-types.

Follow-up questions (dynamic from pageContext) and routing pill
clicks into the main-chat window are deliberate phase-2 items per
PRD and not part of this change.
The agent emits an auto-welcome the moment a conversation is created. We
drop that welcome via hasUserSentRef + a skip-ticket so the UI doesn't
surface it before the shopper has engaged. The ticket was a plain boolean
(`welcomePendingRef`), so sendMessage never waited on it — on a cold first
send (first pill click, or first Send on an untyped InputBar), if the
agent emitted the REAL answer before the welcome, the first Chatbot
`message` event was eaten as "the welcome" and the shopper saw nothing.
Repro from screenshots: 1st pill click → user bubble, 45s of silence,
no SSE answer; 2nd click → full streamed answer.

The InputBar hid this because the shopper spent seconds typing, which
gave the welcome time to arrive and clear the ticket before Send fired.
Pills had no warm window.

Fix: replace the boolean with an awaitable gate. sendMessage now awaits
the gate between ensureConnected() and client.sendMessage(). The gate
closes on any definitive signal — welcome dropped, streaming token
started, 5s welcome-timeout, SESSION_EXPIRED, or remount cleanup — so
the user POST is held only until the welcome has unambiguously been
resolved. Warm sends see pending=false and skip the await in one
microtask, so there's zero latency hit on later clicks or typed sends.

Factored into src/provider/welcome-gate.ts to match the architecture of
ensure-connected.ts, with 12 unit tests covering the three states, the
multi-waiter + re-open cycles, and the sendMessage-race contract. All
previously-passing tests continue to pass (174/174).
@sf-samarth-urs
sf-samarth-urs force-pushed the feature/pdp-opener-pills branch from f34ca4d to 83ff845 Compare October 5, 2026 09:39
The clicked pill is already visible and highlighted above the answer, so
echoing the same text as a <p class="query"> below it is noise. Chintan
flagged this in the PRD review (shoe PDP design keeps pills on top +
answer below, no echo).

Fix: in Response.tsx, derive cameFromPill = state.pdpQuestions.includes(
state.lastQuestion) and gate the echo on !cameFromPill. Typed messages
still echo (matches the conversational pattern shoppers expect for
freeform input). A typed message whose text happens to match a pill
verbatim is also suppressed — acceptable edge case.

No new tests: this is a one-line pure-view derivation on existing state;
the repo doesn't yet have a jsdom + testing-library setup for component
tests, and dragging those in for a single conditional isn't earning
anything. Reducer tests already cover lastQuestion / pdpQuestions state
transitions.
… drain shelf

Aligns the PDP widget to the Shiseido-Sandbox design and fixes two
rough edges called out in local testing:

- Layout: InputBar → Response → QuestionPills. Pills now live BELOW
  the answer (not above it), and the "which question am I looking at?"
  acknowledgement is a right-aligned dark pill-shaped user bubble —
  not a highlighted pill. Removes the duplicate "clicked pill + echoed
  query text" that read as repetitive.

- InputBar placeholder templates the current PDP product name
  ("Ask me anything about <productName>") when the host passes one.
  Explicit `placeholder` config still wins; generic fallback preserved
  for non-PDP contexts. productName sourced from SCAPI's `name` field.

- Pill shelf drains: a clicked pill is hidden (askedQuestions filter)
  so the shopper never looks at a question whose answer is already
  above them. Remaining pills stay available until exhausted; shelf
  then renders nothing and the InputBar carries the shopper forward
  until Shilpi's follow-up pills land in a later PR.

- Welcome-gate fix: streaming_token handler no longer closes the
  welcome gate pre-send. Agentforce streams the welcome message too,
  so welcome tokens were slipping the gate open and letting the
  welcome's final `message` event flash on screen before being
  overwritten. Now gated on hasUserSentRef.

- Dead-air fix on cold first click: dispatch SET_AGENT_TYPING on send
  intent (not on SSE `typing_started`). The ~1-2s handshake window
  was previously silent; the dots are the shopper's "I heard you"
  acknowledgement. Cleared on all error-return paths.

6 new reducer tests cover askedQuestions behavior (append, dedup,
accumulate across sends, clear on RESET_CONVERSATION, initial state).
Follow-ups parked for the next PR with Shilpi: regenerate pill +
dynamically-generated follow-up questions sourced from the latest
answer.
The welcome-gate was a send-side guard only: it held the user POST
until the welcome resolved OR the 5s safety timeout fired. If
Agentforce was slow enough that the welcome `message` arrived AFTER
the timeout AND after the user POST, the receive-side handler saw
hasUserSentRef=true + gate.pending=false and happily rendered the
welcome ("Welcome to NTO!") as if it were the real answer.
Reproduced in an incognito cold load — screenshot in PR.

Fix: a durable `welcomeDroppedRef` that outlives the gate's 5s
timeout. The message handler now treats the FIRST Chatbot message
as the welcome regardless of gate state, flipping the ref so
subsequent Chatbot messages render as answers. streaming_token
mirrors the same guard so welcome tokens from a slow agent don't
paint streamingText as the answer either.

- New ref initialized false on every fresh client; reset false on
  SESSION_EXPIRED and on sendMessage's retry prelude
- Pre-set true on successful tryRestore (no welcome on restore)
- Flipped true the first time any Chatbot message is consumed,
  pre-send OR post-send

Trade-off: if Agentforce ever ships the real answer BEFORE the
welcome (SSE out-of-order), we'd drop the real answer. In practice
Agentforce queues welcome first, so this is safe. If that ever
breaks, we add a lastEventId / correlation check.

180/180 tests still green (existing suite covers the send-side
gate; the receive-side flag is pure WidgetProvider ref logic).
…DK errors

Two bugs observed in an incognito cold load with a slow Agentforce:

1. Welcome arrives in a specific window — after the 5s send-side gate
   timeout fires (gate closes, user POST goes out) but BEFORE the user
   POST completes (hasUserSentRef still false). Prior iteration only
   flipped welcomeDroppedRef when the gate was still pending; this
   window missed, welcomeDroppedRef stayed false, then the real answer
   arriving post-send was treated as "the welcome" and swallowed.

   Fix: pre-send, ANY Chatbot message is the welcome — no gate-state
   dependency. Flip welcomeDroppedRef unconditionally in that branch;
   do the gate-close + SET_CONNECTION_READY only if the gate is still
   pending (otherwise it's already handled by the timeout). The
   post-send branch is unchanged — it catches the case where the
   welcome arrives even later, after the POST completes.

2. The error handler was dispatching SET_ERROR for every SDK error,
   including recoverable ones (SSE reconnects, transient drops). The
   SET_ERROR reducer branch clears lastQuestion + answer, so a
   mid-flight reconnect visibly swallowed the shopper's bubble and
   the in-flight answer with a red "Something went wrong" flash. The
   SDK was going to recover silently anyway.

   Fix: only surface SET_ERROR when e.recoverable === false. Log
   recoverable errors when enableLogging is on. sendMessage()'s own
   retry path still catches terminal failures of the user's POST.

180/180 tests still green. Follow-up: WidgetProvider.tsx is not
currently exercised by any test (no jsdom/testing-library). Welcome-
gate wiring is subtle enough that this bug keeps biting; test harness
for Provider-level handshake flow is the clearest remaining gap.
…module

The welcome-greeting classification logic has broken twice in two days
across three fix iterations — once leaking the welcome as the answer,
once swallowing the real answer as "the welcome". Each time the bug
was in a window the previous hand-rolled branch didn't cover. The
logic itself is simple (3 timing windows); what makes it brittle is
that it was expressed as nested `if`s inside a long-lived SSE
handler, with no test harness around it.

Fix the shape, not just the symptom:

- New module `src/provider/welcome-tracker.ts` holds the entire
  decision table as two pure functions — `classifyMessage` for SSE
  `message` events, `shouldAppendStreamingToken` for the token
  stream. Both take plain booleans; no DOM, no SDK, no refs.
- 12 unit tests in `tests/provider/welcome-tracker.test.ts` cover
  every documented window. The two regressions from this week each
  have a named test ("drops a Chatbot message AFTER the gate has
  already timed out", "drops a Chatbot message as the (late)
  welcome, not the answer") so a future refactor that reintroduces
  either bug fails loudly.
- `WidgetProvider.tsx` now translates classifier verdicts into
  ref writes + reducer dispatches. The handler is the thin edge;
  behavior is the module. Semantics unchanged from the last fix.

Known coverage gap called out separately: WidgetProvider.tsx itself
is still untested (no jsdom harness). This refactor doesn't close
that gap, but it moves the hardest-to-reason-about logic out of
WidgetProvider and into a module that IS fully covered.

192/192 tests green (was 180; +12 welcome-tracker).
@sf-samarth-urs
sf-samarth-urs merged commit c90ec6b into SalesforceCommerceCloud:main Oct 5, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants