Skip to content

feat: add ContactUsLauncher floating support widget - #274

Merged
zamanafzal merged 20 commits into
mainfrom
zafzal/13712-contact-us-launcher
Oct 22, 2026
Merged

zamanafzal merged 20 commits into
mainfrom
zafzal/13712-contact-us-launcher

Conversation

@zamanafzal

@zamanafzal zamanafzal commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

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 below
the sm breakpoint.

  • Exported two ways: a new ./contact-us subpath, which is how mit-learn will
    import it, and a contactUsLauncher bundle with an init() for hosts outside
    the React tree like the edX MFE. ES + UMD, global contactUsLauncher.
  • Icon-only by default; showLabel renders a pill instead. Same accessible name
    either way.
  • Non-modal. An outside click closes it only while there's nothing to lose —
    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 body with 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.
  • transformBody collapses the Vercel messages array into learn-ai's single
    message shape and sets clear_history, so each card session starts a fresh
    thread rather than replaying the last visit's ticket.

The shared-component changes are the part worth reviewing — five, in
AiDrawer and AiChat:

Change Who it reaches
AiDrawer renders chat for any blockType that isn't "video" (was "problem" only) callers passing no blockType, who used to get a header above an empty body
New optional settings.badge, rendered as a chip beside the title the badge itself is opt-in, but making room for it changed the title h1 from flex: 1 to flex: "0 1 auto", which reaches every AiDrawer: the heading no longer spans the header, so the keyboard focus ring hugs the title text instead
Chat input's accessible name follows placeholder instead of always t(aiChat.askQuestion) (WCAG 2.5.3), and EntryScreen takes a placeholder it now renders visibly, where it rendered none opt-in — no existing consumer passes a placeholder, so tutor, syllabus and video keep the fallback name and an unplaceheld entry-screen input
overflowWrap: "break-word" on message bubbles every AiChat: a long URL or error code used to be clipped, with no horizontal scrollbar to chase it
New optional trapFocus on AiDrawer's slot variant, default true opt-out only — the MFE slot keeps trapping Tab; the floating card sets false so the keyboard can reach the page behind without Escape discarding the draft

transformBody's return type also widens from Iterable<unknown> to object,
which is what lets {...requestBody, ...transformBody(messages)} build a real
body 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 typecheck and yarn lint-check clean.

Storybook, no backend — yarn start → smoot-design/AI/ContactUsLauncher, MSW standing in for the bot across all three turns.

  • Default — click the launcher; the card opens over it and the launcher hides.
  • Type a few words, click the page behind the card, then press Escape with focus on the body. It stays open with the draft intact. Clear the field and click outside — now it closes, and focus returns to the launcher.
  • Reopen and run the intake: describe a problem, give an address, check the review, reply "send", get a reference. From the first send on, a click on the page no longer closes it — the close button and Escape still do.
  • Below the sm breakpoint the card goes full-screen. Request fails shows the error alert; the retry continues the same thread rather than clearing history again, since clear_history flips before the request goes out. Label shown / Custom label cover the pill and a caller-supplied name.
  • Keyboard — Tab to the launcher and press Enter. The card's heading takes focus with a visible ring. Type a few words, then Tab past the last control in the card: focus leaves to the page behind, the card stays open, and the draft survives.
  • Mobile host chrome — below sm, with a fixed host header on the page at z-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

  • From this branch, Node 24: yarn pack -o ../smoot-design-contact-us.tgz. prepack runs the full build, so dist/esm/contact-us.js and the bundles are in the tarball.
  • In mit-learn's root package.json, add "@mitodl/smoot-design": "file:../smoot-design-contact-us.tgz" to resolutions, then yarn install. The published pin is 6.38.0, which has no ./contact-us subpath, so without this the host import doesn't resolve.

2. Run learn-ai with the support bot

  • Check out Add a support intake bot that files Zendesk tickets learn-ai#98 (zafzal/13712-support-intake) and docker compose up. Django is on 8001, nginx on NGINX_PORT=8002.
  • docker compose run --rm web python manage.py migrate — main/migrations/0004_support_rate_limit.py seeds the support_bot throttle (100/day authenticated, 50/day anonymous). Looping the flow will hit it.
  • Set AI_ZENDESK_STUB_MODE=True in env/backend.local.env to exercise the whole path without creating tickets: filing returns a STUB-XXXXXXXX reference and learn-ai logs a warning. For real filing, set AI_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

  • The host side is ContactUsLauncherGate in frontends/main/src/page-components/ContactUsLauncher/, mounted in app/(site)/layout.tsx.
  • In 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 at http://ai.open.odl.local:8002/http/support_agent/ also works, but then learn-ai's CORS_ALLOW_ORIGINS has to name http://open.odl.local:8062.
  • Enable the PostHog contact-us flag 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.
  • Bring mit-learn up and open http://open.odl.local:8062.

4. Walk the flow

  • Signed out — launcher bottom-right → describe a problem → the bot asks for a reply-to address → give one → it shows the request back for review → reply "send" → it answers with the ticket reference.
  • Signed in — the gate puts the session email in settings.chat.requestBody.user_email, so the bot skips the email ask and invites more detail before the review.
  • Network tab — the POST body is {page_url, message, clear_history} merged over the host's requestBody. page_url is the page you opened the card on, read at send rather than at mount. clear_history is true only on the first send of a card session; close and reopen to start a second request and confirm it flips back to true.
  • Ticket — in stub mode the reference is STUB-… and nothing reaches Zendesk. Against a real instance the ticket lands tagged tim_support_intake, with page URL, name and username in the custom fields.
  • One ticket per thread — send again in the same card after filing; the bot reads back the existing reference instead of filing a second one.

Additional Context

Known gaps:

  • z-index is inert in the bundle path (ContactUsLauncher's z-index cannot lift it above host content in the bundle path #276). The full-screen mobile card still
    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 inert and there's no scroll lock.
  • Hardcoded English strings — label, badge, placeholder and greeting all bypass
    TranslationContext. aiDrawer.videoEntryScreenTitle is the pattern to
    follow.
  • init() duplicates aiDrawerManager.tsx and the two have already diverged.
  • A host passing page_url: null now gets the current location instead.
    Deliberate: learn-ai's URLField rejects null, so the old behaviour was a
    guaranteed 400.
  • The mit-learn host side in step 3 lives on zafzal/13712-contact-us-launcher
    and is not pushed yet, so nothing mounts this on a deployed environment.

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 zamanafzal changed the title Add a ContactUsLauncher floating support widget feat: add ContactUsLauncher floating support widget Oct 12, 2026
@zamanafzal
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.
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.
@zamanafzal zamanafzal added the Needs Review An open Pull Request that is ready for review label Oct 21, 2026

@ahtesham-quraish ahtesham-quraish left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Things looks fine to me. There are two bugs which you should check before merging

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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.
@zamanafzal
zamanafzal merged commit c182017 into main Oct 22, 2026
9 checks passed
@zamanafzal
zamanafzal deleted the zafzal/13712-contact-us-launcher branch October 22, 2026 08:32
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 6.39.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs Review An open Pull Request that is ready for review released

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants