Skip to content

feat(teams): let a card carry a text input - #959

Open
mmahmed wants to merge 1 commit into
vercel:mainfrom
mmahmed:feat/card-text-input
Open

mmahmed wants to merge 1 commit into
vercel:mainfrom
mmahmed:feat/card-text-input

Conversation

@mmahmed

@mmahmed mmahmed commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

On Teams, collecting a line of text from a user meant opening a dialog. The SDK already had TextInput, but only as a ModalChild — usable behind a Task Module, never on the card itself. So "Approve, and tell me why" cost a second round trip and a whole dialog surface for one sentence.

This makes TextInput a CardChild and plumbs what was typed back to the handler.

  • chatTextInput joins the CardChild union, so it can sit on a card next to the buttons that submit it. ActionEvent gains values?: Record<string, string>, the card's own input values keyed by input id, deliberately the same shape as ModalSubmitEvent.values so a card action and a dialog submit are read the same way. It is optional and absent on platforms with no card inputs, so no existing call site changes. The builder and its types live in a new leaf module, packages/chat/src/text-input.ts, imported by both cards.ts and modals.ts — see Bundle below for why.
  • @chat-adapter/teams — renders it as an Adaptive Card Input.Text through the same textInputToAdaptive the dialog converter uses, so an input looks the same on a card as in a Task Module (modals.ts dropped its private copy). The dependency-free @chat-adapter/teams/cards subpath emits the same element and gains no dependency (+336 B).
  • Webhooks — a button's callbackUrl POST now carries values, so a flow driven by webhooks rather than an onAction handler still receives what was typed. JSON.stringify drops it when the card had no inputs.

Reading the inputs back out

Teams merges the card's input values into the same flat Action.Submit object the adapter uses for its own bookkeeping. RESERVED_SUBMIT_KEYS (actionId, value, msteams) names those keys and cardInputValues() reads everything else, keeping string and number (numbers stringified exactly as parseDialogSubmitValues does). All four readers — plain actions, adaptive-card invokes, dialog opens and the auto-submit fan-out — share that one set, because if two of them skipped different keys an input would be reported down one path and silently dropped down another.

Consequence worth knowing, now documented on the Teams adapter page: an input whose id is actionId, value or msteams is indistinguishable from the adapter's own key and is dropped. value is the sharp one — it also overwrites the button's payload, which is where a callbackUrl token rides, so such a button would stop firing its callback. There is a test pinning this.

Behavior change on the auto-submit path

fanOutAutoSubmit previously filtered only actionId and msteams; it now filters the shared reserved set, which adds value. An auto-submit input whose id is literally value used to fan out as its own action and no longer does. Deliberate — it is what makes all four readers agree — but it is a narrowing, so calling it out.

Platform fallback

A new member on the CardChild union needs three separate fallback-text switches updated in this repo, and ~15 converters inherit one of them: cardChildToFallbackText (chat/src/cards.ts), BaseFormatConverter.cardChildToFallbackText (chat/src/markdown.ts) and the plain-object copy in adapter-teams/src/cards-primitives. All three handle text_input by returning the label, and adapter-shared/src/card-utils.ts picks it up through its delegating default:.

That still does not give every platform a fallback, and the docs now say so rather than promising one: Slack, Google Chat, Discord, GitHub and Linear reach the core helper through a default: arm and show the label; WhatsApp, Messenger, Twilio and X return []/null and omit the input entirely. Making those four render the label would mean touching four packages this PR otherwise does not, so it is left to their maintainers and the docs, the API reference and the changeset all name the split.

Bundle

Sharing the builder naively — a value import from cards.ts into modals.ts — merged modals.ts into the same pre-bundled chunk as cards.ts, so the published chat/workflow entry shipped and executed the whole modal builder (Modal, DateInput, Select, …) while using only Actions, Button, Card and Text: +6,757 B raw, ~1.2 KB gzip, +28% of that subpath's closure. Hence the leaf module. The built chat/workflow chunk now contains zero modal builders, and the main chat entry is unchanged either way.

Semver

CardChild gained a member, shipped as minor, matching the precedent set when ChartElement joined the same union. Source-breaking only for an out-of-repo adapter that exhausts the union with a never check; every in-repo converter reaches it through a default:. The changeset says so.

Test plan

  • pnpm check, pnpm typecheck (44/44), pnpm knip, pnpm konsistent, pnpm build (27/27): clean.
  • pnpm --filter @chat-adapter/teams test: 402 pass. New coverage for the Input.Text mapping on both subpaths; values on message actions, adaptive-card invokes and the auto-submit fan-out; reserved-key collision; non-string/number values discarded; and the label fallback.
  • pnpm --filter chat test: TextInput survives Card(), the JSX runtime and fromReactElement, and all three fallback switches are covered.
  • The cross-subpath parity test now pins the key set the primitives path owns and compares values both ways. It previously used toMatchObject, which passes even when the primitives converter drops a field, and keyed on :tada: — a placeholder neither converter transforms, so it could not have caught drift.

Three suites fail on this machine and fail identically on a clean f034cd27 checkout with this branch absent, so they are not from this change:

  • create-chat-sdkpnpm pack --dry-run exceeds the 5s vitest timeout under Turbo's parallel load; passes standalone in 3s.
  • @chat-adapter/gmail — two boundary.test.ts timeouts, same cause.
  • @chat-adapter/integration-testsgmail has a custom OG image (1 of 1531); genuinely red on main.

Excluding those three, pnpm turbo test is 47/47 green.

Checklist

  • All commits are signed and verified
  • All commits are signed off for the DCO (git commit -s)
  • pnpm validate passes — every stage green except the three suites above, which fail the same way on main without this branch
  • Changeset added (or N/A — see CONTRIBUTING.md)
  • Documentation updated (or N/A)

- TextInput becomes a CardChild so a reply can be typed without opening a dialog
- report the card's inputs as ActionEvent.values, matching ModalSubmitEvent.values
- forward those values on a button's callbackUrl POST, which has no handler to read them
- render the label as fallback text wherever a platform cannot draw an input
- share one Input.Text mapping between the card and dialog converters
- convert emoji placeholders in an input label on the cards subpath, as the adapter does
- keep the TextInput builder in its own leaf module so chat/workflow does not
  bundle the modal builders it never calls
- name which adapters show the label and which drop the input, rather than
  promising a fallback four of them do not implement

Signed-off-by: Mohammed Mansoor Ahmed <mansoorahmed.mohammed@gmail.com>
@mmahmed
mmahmed requested a review from a team September 18, 2026 17:20
@vercel

vercel Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

@mmahmed is attempting to deploy a commit to the Vercel Team on Vercel.

A member of the Team first needs to authorize it.

@vercel-security-reviewer

Copy link
Copy Markdown

Security review details

This branch has not been deployed

No deployments
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.

1 participant