Repository navigation
feat: add ContactUsLauncher floating support widget - #274
Merged
Merged
Conversation
Adds a fixed-position "Contact us" pill that opens a corner card wrapping AiChat, pointed at learn-ai's support intake bot, so a learner can report a problem without navigating away from the page they are on. The card is non-modal and dismisses on an outside click until the learner sends something, after which only Escape and the close button close it, so a half-written support request is not thrown away by a stray click. Also ships a standalone bundle, ES and UMD with the global contactUsLauncher, so hosts outside the React tree such as the Open edX Learning MFE can mount it from a script tag.
Review follow-ups on the ContactUsLauncher, each with a test: - An outside click leaves the card open once the learner has typed something, not only once they have sent. Draft text lives in the card's own state, so closing loses it. - Escape is ignored while focus sits in a host element, matching how AiDrawer scopes its slot Escape. - page_url defaults to window.location.href, so a host that configures only apiUrl still records where the learner was. A host-supplied value wins. - entryScreenEnabled and entryScreenTitle are applied as defaults, so a host's translated title is no longer dropped. - init stamps smoot-contact-us-root only on a container it created itself, leaving a host element's id alone.
The body fallback is not about the hidden launcher: AiDrawer focuses the slot heading on open, so focus is already inside the card by then. It is about the send, which disables the Send button that had focus and drops focus to body while the card is still the only thing on screen. Also drops an overstated comparison: AiDrawer gates its slot Escape on focus, but has no body fallback of its own.
…ay Escape Two regressions from the review fixes: page_url was captured in a useMemo at mount. A host that routes client-side changes the URL under a card that init() renders only once, so a learner who moved on before reporting filed the ticket against the wrong page. It now reads at send time, with a host-supplied page_url still winning. The Escape body fallback exists for the post-send case, where sending disables the Send button that had focus. It also fired when the learner clicked the page mid-draft, discarding the description the click-away guard was written to protect. It now consults hasDraft() the same way.
The pushState ran before the card was opened, so an implementation that captured the URL when the card opened passed it too. Verified by building that implementation: the old ordering passes against it, the new one fails.
The card opened on the entry screen's bare heading, so a learner who clicked "Contact us" landed on a chat box with nothing explaining that what they type becomes a support request. The entry screen has room for a heading and nothing else, so the greeting goes in as an initial assistant message and the entry screen goes away. Hosts can still replace it with their own initialMessages.
… problem Adds two opt-in AiDrawer settings and uses them from the launcher: - settings.badge renders a chip beside the title. The launcher sets "AI ASSISTANT", which is what makes the disclaimer underneath make sense. Unset everywhere else, so the AskTIM drawer is unchanged. - settings.chat.placeholder reaches the chat input. The launcher asks the learner to "Describe the problem" rather than ask a question the support bot cannot answer. Two shared fixes in AiChat along the way: - A long URL or error code had nowhere to break, and in slot variant the messages container has overflow: visible, so the overflow was clipped with no scrollbar to chase it with. overflow-wrap: break-word on the message makes it flow down instead. - The input's accessible name was hardcoded to "Ask a question", so any consumer setting a placeholder got a visible label disagreeing with its name - unreachable by speech input (WCAG 2.5.3). It now follows the placeholder and falls back to the old string. The wrap has no unit test: jsdom has no layout, so the assertion could only read the CSS back at itself. Verified in Storybook at 400px - the message wraps to three lines, scrollWidth matches clientWidth, and the card stays 400px wide.
The mock filed the ticket the moment an address arrived, which is the flow the backend had before the review step landed. Anyone opening the story saw behaviour the bot no longer has. It now plays all three turns the way learn-ai does: ask for an address, show the ticket for review, then file on the turn after. State resets on clear_history, which is what the card sends when it opens a new thread.
…hat placeholder The entry screen's textbox was always named via a fixed translation key, while the chat screen beyond it is named by the caller's placeholder. A voice-input user who matches on name loses their target the moment the entry screen gives way to the chat (WCAG 2.5.3). Thread placeholder through so both screens share one name, falling back to the old default when none is given.
…ar FAB Swap the text-pill launcher for a circular ActionButton carrying only the AI-chat icon, named by an aria-label rather than visible text — the FAB shape reads as "open a chat" the way the pattern common to chat widgets does. Tests now assert on accessible name instead of textContent, including the one exercising a host-owned mount container.
… text The launcher is icon-only by default: a floating chat bubble reads as "open a chat" the way the FAB pattern common to chat widgets does. A host that wants the label visible next to the icon can now opt in, and gets a pill rather than a circle once text drives the width. The accessible name is unchanged either way.
zamanafzal
marked this pull request as ready for review
October 14, 2026 11:36
… subpath
mit-learn mounts the launcher by importing the component rather than
loading the prebuilt bundle the edX MFE uses, so it needs a package
entry point:
import { ContactUsLauncher } from "@mitodl/smoot-design/contact-us"
Mirrors the existing ./ai subpath. tsc already compiles everything under
src/, so the entry file is all the build needs.
showLabel had a test but nothing to look at in Storybook, so the pill variant was unreviewable without editing the story source.
This was referenced Oct 20, 2026
A container init creates always has an empty id, so every init() call without a host container stamped the same smoot-contact-us-root. Two launchers on a page meant a duplicate id. Nothing reads the id, so drop it rather than making it unique.
The entry screen took `placeholder` only to build its accessible name, so a consumer passing one got a field that looked empty but answered to "Describe what you need". WCAG 2.5.3 is about the name matching text the user can see, which there was none of; the chat input it is meant to match sets both. No consumer hits this today - ContactUsLauncher keeps the entry screen off - so nothing visible changes yet.
…g the draft The slot variant traps Tab, which is right for a drawer that fills the page and wrong for a card floating over one the learner can still use. The click-away guard deliberately keeps a draft when they click the page to read an error code off it; by keyboard that reach was impossible, because the only way out of the trap was Escape, which closes the card and discards what was typed. AiDrawer grows a `trapFocus` prop, defaulting true so the MFE slot is unchanged, and the launcher opts out.
ahtesham-quraish
approved these changes
Oct 22, 2026
ahtesham-quraish
left a comment
There was a problem hiding this comment.
Things looks fine to me. There are two bugs which you should check before merging
There was a problem hiding this comment.
🟡 Changes recommended
Retry handling, outside-card Escape behavior, and keyboard focus visibility need correction before approval.
3 open issues
What changed in this PR
Adds a floating “Contact us” support widget that reuses AiDrawer and AiChat for learn-ai’s support intake, available to both React applications and standalone hosts.
Changes:
- Adds a responsive launcher and support card with draft protection and session request transformation.
- Extends shared chat components with optional badges, placeholders, focus-trap control, and message wrapping.
- Adds component tests, mocked Storybook flows, and package/build entry points.
| File | Description |
|---|---|
| vite.config.contactUs.mts | Configures the standalone launcher bundle. |
| src/contact-us.ts | Exports the React component and props. |
| src/components/AiChat/EntryScreen.tsx | Supports placeholders and matching accessible names. |
| src/components/AiChat/AiChat.tsx | Wraps long messages and updates input naming. |
| src/components/AiChat/AiChat.test.tsx | Tests placeholder and accessible-name behavior. |
| src/bundles/ContactUsLauncher/ContactUsLauncher.tsx | Implements the launcher, card, and session handling. |
| src/bundles/ContactUsLauncher/ContactUsLauncher.test.tsx | Tests interactions, request bodies, and session behavior. |
| src/bundles/ContactUsLauncher/ContactUsLauncher.stories.tsx | Demonstrates mocked intake and error flows. |
| src/bundles/contactUsLauncher.tsx | Adds standalone mounting and cleanup. |
| src/bundles/contactUsLauncher.test.tsx | Tests host-container preservation and cleanup. |
| src/bundles/AiDrawer/AiDrawer.tsx | Adds generic chat rendering and optional card settings. |
| src/bundles/AiDrawer/AiDrawer.test.tsx | Tests generic chat, badges, and placeholders. |
| scripts/build.sh | Includes the launcher bundle in builds. |
| package.json | Adds the build command and ./contact-us export. |
Review effort: Balanced
Opening moved focus to the card heading without passing openedViaKeyboard, so AiDrawer suppressed the heading's outline while the launcher was already hidden - a keyboard open left no focus indicator anywhere (WCAG 2.4.7). The full-screen mobile card also stayed at fab + 1 (1051), under a host's fixed AppBar (1100), which paints over the heading and Close button. With no Escape key on a phone that leaves no way out, so it now sits at modal level below the sm breakpoint only; desktop ordering is unchanged.
…t covers "Costs nothing when the first send failed outright" only holds when there is no thread cookie. A returning learner has one, and a first send that dies before reaching learn-ai leaves it live for the retry to continue. Behaviour is unchanged - the tradeoff is deliberate - but the comment claimed a safety it does not have.
|
🎉 This PR is included in version 6.39.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


What are the relevant tickets?
Part of https://github.com/mitodl/hq/issues/13712
Description (What does it do?)
Frontend half of the "Contact us" support intake. Backend half is
mitodl/learn-ai#98.
A fixed-position circular launcher that opens a corner card wrapping
AiDrawer's slot variant, pointed at learn-ai's support bot. Full-screen belowthe
smbreakpoint../contact-ussubpath, which is how mit-learn willimport it, and a
contactUsLauncherbundle with aninit()for hosts outsidethe React tree like the edX MFE. ES + UMD, global
contactUsLauncher.showLabelrenders a pill instead. Same accessible nameeither way.
once the learner has typed or sent, a stray click leaves it alone. Escape is
deliberately looser: it closes whenever focus is in the card, and also when
focus has dropped to
bodywith no draft in the field. So after a send,clicking the page and then pressing Escape does close the card. Accepted for
now — focus alone can't separate that from the Send-becomes-Stop case, which
also leaves focus on
body.transformBodycollapses the Vercel messages array into learn-ai's singlemessageshape and setsclear_history, so each card session starts a freshthread rather than replaying the last visit's ticket.
The shared-component changes are the part worth reviewing — five, in
AiDrawerandAiChat:AiDrawerrenders chat for anyblockTypethat isn't"video"(was"problem"only)blockType, who used to get a header above an empty bodysettings.badge, rendered as a chip beside the titleh1fromflex: 1toflex: "0 1 auto", which reaches everyAiDrawer: the heading no longer spans the header, so the keyboard focus ring hugs the title text insteadplaceholderinstead of alwayst(aiChat.askQuestion)(WCAG 2.5.3), andEntryScreentakes aplaceholderit now renders visibly, where it rendered noneplaceholder, so tutor, syllabus and video keep the fallback name and an unplaceheld entry-screen inputoverflowWrap: "break-word"on message bubblesAiChat: a long URL or error code used to be clipped, with no horizontal scrollbar to chase ittrapFocusonAiDrawer's slot variant, defaulttruefalseso the keyboard can reach the page behind without Escape discarding the drafttransformBody's return type also widens fromIterable<unknown>toobject,which is what lets
{...requestBody, ...transformBody(messages)}build a realbody instead of index keys.
Screenshots (if appropriate):
Video:
https://drive.google.com/file/d/18fLvCNmY3hcyrWzSZUJ2saj7Z1NHRxBo/view?usp=drive_link
How can this be tested?
Automated (this repo)
yarn jest— 23 suites, 215 tests.yarn typecheckandyarn lint-checkclean.Storybook, no backend —
yarn start→ smoot-design/AI/ContactUsLauncher, MSW standing in for the bot across all three turns.smbreakpoint the card goes full-screen. Request fails shows the error alert; the retry continues the same thread rather than clearing history again, sinceclear_historyflips before the request goes out. Label shown / Custom label cover the pill and a caller-supplied name.sm, with a fixed host header on the page atz-index: 1100, the card's heading and × stay visible and clickable. Paste this in the console on the story canvas to stand one up:const b=document.createElement("div");b.style.cssText="position:fixed;top:0;left:0;right:0;height:60px;z-index:1100;background:red";document.body.appendChild(b)End to end: mit-learn → learn-ai → Zendesk
1. Get this branch into mit-learn
yarn pack -o ../smoot-design-contact-us.tgz.prepackruns the full build, sodist/esm/contact-us.jsand the bundles are in the tarball.package.json, add"@mitodl/smoot-design": "file:../smoot-design-contact-us.tgz"toresolutions, thenyarn install. The published pin is 6.38.0, which has no./contact-ussubpath, so without this the host import doesn't resolve.2. Run learn-ai with the support bot
zafzal/13712-support-intake) anddocker compose up. Django is on 8001, nginx onNGINX_PORT=8002.docker compose run --rm web python manage.py migrate—main/migrations/0004_support_rate_limit.pyseeds thesupport_botthrottle (100/day authenticated, 50/day anonymous). Looping the flow will hit it.AI_ZENDESK_STUB_MODE=Trueinenv/backend.local.envto exercise the whole path without creating tickets: filing returns aSTUB-XXXXXXXXreference and learn-ai logs a warning. For real filing, setAI_ZENDESK_API_URL, the OAuth client id/secret, and all three custom-field ids (AI_ZENDESK_URL_FIELD_ID,AI_ZENDESK_NAME_FIELD_ID,AI_ZENDESK_USERNAME_FIELD_ID) — Zendesk rejects the whole ticket on an unknown id, so a wrong one reads as a backend failure rather than a field problem.3. Point mit-learn at it
ContactUsLauncherGateinfrontends/main/src/page-components/ContactUsLauncher/, mounted inapp/(site)/layout.tsx.env/frontend.local.env:NEXT_PUBLIC_LEARN_AI_SUPPORT_ENDPOINT=http://open.odl.local:8065/ai/http/support_agent/. APISIX proxies/ai/*to learn-ai, strips the prefix, passes anonymous traffic through and sets permissive CORS, so nothing else is needed. Pointing straight athttp://ai.open.odl.local:8002/http/support_agent/also works, but then learn-ai'sCORS_ALLOW_ORIGINShas to namehttp://open.odl.local:8062.contact-usflag for your user. The gate renders nothing until flags load and the flag is on, so "no launcher" is the expected result when either is missing.http://open.odl.local:8062.4. Walk the flow
settings.chat.requestBody.user_email, so the bot skips the email ask and invites more detail before the review.{page_url, message, clear_history}merged over the host'srequestBody.page_urlis the page you opened the card on, read at send rather than at mount.clear_historyistrueonly on the first send of a card session; close and reopen to start a second request and confirm it flips back totrue.STUB-…and nothing reaches Zendesk. Against a real instance the ticket lands taggedtim_support_intake, with page URL, name and username in the custom fields.Additional Context
Known gaps:
isn't a modal (The full-screen ContactUsLauncher card on mobile is not a modal #277) — it now paints above host chrome, but nothing behind it
is
inertand there's no scroll lock.TranslationContext.aiDrawer.videoEntryScreenTitleis the pattern tofollow.
init()duplicatesaiDrawerManager.tsxand the two have already diverged.page_url: nullnow gets the current location instead.Deliberate: learn-ai's
URLFieldrejects null, so the old behaviour was aguaranteed 400.
zafzal/13712-contact-us-launcherand is not pushed yet, so nothing mounts this on a deployed environment.