Skip to content

feat(apollo-react): add STT dictate and voice interaction buttons [JAR-9545]#571

Merged
dsuresh-ap merged 5 commits into
mainfrom
feature/stt-voice-interaction-JAR-9545
Apr 24, 2026
Merged

feat(apollo-react): add STT dictate and voice interaction buttons [JAR-9545]#571
dsuresh-ap merged 5 commits into
mainfrom
feature/stt-voice-interaction-JAR-9545

Conversation

@dsuresh-ap

@dsuresh-ap dsuresh-ap commented Apr 22, 2026

Copy link
Copy Markdown
Member

Summary

Ports apollo-design-system#5094 into the open-source apollo-react chat package. The old push-to-talk AutopilotChatAudio combo (mic + speaker, gated by disabledFeatures.audio) is replaced with two explicit, independently-gated voice affordances:

  • STT dictate button (mic icon, gated by disabledFeatures.audio) — UI state only. Emits SpeechToTextToggle; consumer apps own the recognizer (e.g. Azure Speech SDK) and stream transcripts back via setPrompt().
  • Always-on voice interaction button (waveform icon, gated by new disabledFeatures.audioStreaming) — replaces the send button when the input is empty. Streams mic audio via InputStream events and plays model audio via OutputStream events. Input chrome collapses while active (textarea, error, left actions hidden; border/shadow removed).

Jira: JAR-9545

Changes

  • Service (ChatService.ts): publishSpeechToTextToggle(), setSpeechToTextState(), isSpeechToTextActive getter. New audioStreaming: true default.
  • Types (ChatModel.ts): SpeechToTextToggle + SetSpeechToTextState events; audioStreaming?: boolean on AutopilotChatDisabledFeatures.
  • New component (always-on-voice-button.tsx): reuses the existing useAudioInput / useAudioOutput hooks; exports VoiceButtonContainer so the STT mic button can share its active-state styling.
  • Input UI (chat-input-actions.tsx, chat-input.tsx): send↔voice swap, STT auto-stop on submit (prevents transcript re-population after the input clears), collapse chrome during voice interaction.
  • Deleted: components/audio/chat-audio.tsx. The chat-audio-input.ts / chat-audio-output.ts hooks stay.
  • Dead-code cleanup: onPress / onRelease discriminated union removed from action-button.tsx — the deleted push-to-talk button was its only caller.
  • i18n: Two new Lingui strings (autopilot-chat.input.actions.dictate, autopilot-chat.input.actions.voice-interaction) added to en.json.
  • Docs: DOCS.md — new Speech To Text + Voice Interaction sections with consumer-wiring examples.
  • Showcase: ApChatShowcase.tsx — STT Dictate / Voice Interaction toggles plus fake consumers that simulate a recognizer (word-by-word transcript) and a voice model (streaming assistant reply on activityStart), so both flows are exercisable without real services.

Why now

Aligns the open-source chat with the internal apollo-design-system behavior so consumers that adopt @uipath/apollo-react get the same voice affordances without needing to port this manually.

Reviewer notes

  • This repo never had the intermediate recordingButton / publishRecordingButtonClick / isRecording API — the upstream delete-half of the diff is a no-op here.
  • Lingui rather than the upstream t() helper for i18n, matching every other string in ap-chat.
  • pnpm i18n:extract is broken on main (pre-existing duplicate id autopilot-chat.error.multiple-files across attachements-provider.tsx:112 and dropzone.tsx:70). I hand-added the two new English strings to en.json as a workaround — once the extract bug is fixed, translators pick the keys up in the normal cycle. Flagged as follow-up.

Test plan

  • pnpm turbo run build --filter=@uipath/apollo-react --filter=react-playground succeeds
  • tsc --noEmit clean in both apollo-react and react-playground
  • biome lint clean on all changed files
  • Manual (primary): pnpm dev:react-playground, Components → Chat:
    • Tick STT Dictate → mic button appears; tooltip "Dictate"; click streams canned phrase into input via fake consumer; click again stops
    • While dictating, click Send → input clears AND dictation stops (auto-stop-on-submit)
    • Tick Voice Interaction with empty input → send arrow swaps to waveform; click → chat chrome collapses, console logs [voice] activityStart, canned assistant reply streams in; click stop → chrome returns, console logs [voice] activityEnd
    • Type a character with Voice Interaction ticked → waveform reverts to send; clear → comes back
    • Both ticked simultaneously: waveform button disabled while STT active; STT mic hidden while voice interaction active
    • All three modes (SideBySide / FullScreen / Embedded) and dark theme render correctly

Follow-ups (not part of this PR)

  1. Fix pnpm i18n:extract — resolve the duplicate autopilot-chat.error.multiple-files message id, then regenerate catalogs so translators can localize the two new English strings.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings April 22, 2026 21:04
@github-actions

github-actions Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (PT)
apollo-design 🟢 Ready Preview, Logs Apr 24, 2026, 08:27:17 AM
apollo-docs ❌ Failed: Building: Summary: /vercel/path0/.turbo/runs/3CoFvi9sSfQAviuXnBKlwLos6jz.json Building: Failed: ... N/A, Logs Apr 24, 2026, 08:24:13 AM
apollo-landing 🟢 Ready Preview, Logs Apr 24, 2026, 08:23:49 AM
apollo-ui-react 🟢 Ready Preview, Logs Apr 24, 2026, 08:25:55 AM
apollo-vertex 🟢 Ready Preview, Logs Apr 24, 2026, 08:25:11 AM

@github-actions github-actions Bot added the size:XXL 1,000+ changed lines. label Apr 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Dependency License Review

  • 1941 package(s) scanned
  • ✅ No license issues found
  • ⚠️ 3 package(s) excluded (see details below)
License distribution
License Packages
MIT 1701
ISC 89
Apache-2.0 61
BSD-3-Clause 28
BSD-2-Clause 23
BlueOak-1.0.0 8
MPL-2.0 5
MIT OR Apache-2.0 3
MIT-0 3
CC0-1.0 3
LGPL-3.0-or-later 2
(MIT OR Apache-2.0) 2
Unlicense 2
Python-2.0 1
CC-BY-4.0 1
(MPL-2.0 OR Apache-2.0) 1
Unknown 1
Artistic-2.0 1
(WTFPL OR MIT) 1
(BSD-2-Clause OR MIT OR Apache-2.0) 1
CC-BY-3.0 1
0BSD 1
(MIT OR CC0-1.0) 1
MIT AND ISC 1
Excluded packages
Package Version License Reason
@img/sharp-libvips-linux-x64 1.2.4 LGPL-3.0-or-later LGPL pre-built binary, not linked
@img/sharp-libvips-linuxmusl-x64 1.2.4 LGPL-3.0-or-later LGPL pre-built binary, not linked
khroma 2.1.0 Unknown MIT per GitHub repo, missing license field in package.json

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.

Pull request overview

Ports the apollo-design-system voice UX into packages/apollo-react ap-chat by replacing the legacy push-to-talk audio controls with two independently gated voice affordances (STT dictate + always-on voice interaction) and wiring the corresponding service events, docs, and playground showcase.

Changes:

  • Added STT dictate state/events to AutopilotChatService (SpeechToTextToggle, SetSpeechToTextState) and disabledFeatures.audioStreaming.
  • Updated chat input UI to (a) add an STT mic button, (b) swap send↔voice-interaction when input is empty, and (c) collapse input chrome while voice interaction is active.
  • Added docs + i18n strings + a react-playground demo consumer to exercise both flows.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/apollo-react/src/material/components/ap-chat/service/ChatService.ts Adds STT state management and publishes new STT-related events; adds default audioStreaming: true in disabled features.
packages/apollo-react/src/material/components/ap-chat/service/ChatModel.ts Extends event enum and disabled-features type with STT + audioStreaming.
packages/apollo-react/src/material/components/ap-chat/locales/en.json Adds English strings for “Dictate” and “Voice interaction”.
packages/apollo-react/src/material/components/ap-chat/components/input/chat-input.tsx Collapses/hides input chrome and error while voice interaction is active; passes voice-active state down.
packages/apollo-react/src/material/components/ap-chat/components/input/chat-input-actions.tsx Replaces legacy audio UI with STT mic + always-on voice button; adds STT auto-stop-on-submit.
packages/apollo-react/src/material/components/ap-chat/components/input/always-on-voice-button.tsx Introduces the always-on voice interaction button using existing audio input/output hooks.
packages/apollo-react/src/material/components/ap-chat/components/common/action-button.tsx Removes press/release union props tied to the deleted push-to-talk UI.
packages/apollo-react/src/material/components/ap-chat/components/audio/chat-audio.tsx Deletes the legacy push-to-talk + speaker combo component.
packages/apollo-react/src/material/components/ap-chat/DOCS.md Documents STT + voice interaction behaviors and consumer wiring patterns.
apps/react-playground/src/pages/components/ApChatShowcase.tsx Adds toggles + fake STT/voice consumers so both flows can be exercised without external services.

Comment on lines +187 to +192
// Show voice interaction button when audioStreaming is enabled and either
// voice is already active or the input is empty (no text to submit).
const showVoice =
disabledFeatures.audioStreaming === false &&
(isVoiceInteractionActive || (disableSubmit && !waitingResponse));

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

showVoice is derived from disableSubmit, but disableSubmit can be true even when the input is non-empty (e.g. attachments are still loading, skeletonLoader). In those cases the send button would incorrectly swap to the voice-interaction button even though the user has text/attachments to submit. Consider passing an explicit isInputEmpty/hasSubmittableContent prop from chat-input.tsx (based on message + attachments) and use that instead of disableSubmit for the send↔voice swap condition.

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +80
const [isSpeechToTextActive, setIsSpeechToTextActive] = React.useState(false);
React.useEffect(() => {
if (!chatService) {
return;
}
return chatService.on(AutopilotChatEvent.SetSpeechToTextState, (isActive: boolean) => {
setIsSpeechToTextActive(isActive);
});
}, [chatService]);

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

isSpeechToTextActive local state is always initialized to false and only updates after SetSpeechToTextState fires. If the input actions component mounts while STT is already active (e.g. the service instance persists across remounts), the mic button styling will be out of sync until the next event. Initialize state from chatService.isSpeechToTextActive (and/or set it once inside the effect before subscribing).

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +69
React.useEffect(() => {
if (!chatService || !isActive) {
return;
}
return chatService.on(
AutopilotChatEvent.OutputStream,
(event: AutopilotChatOutputStreamEvent) => {
if (event.mediaChunks) {
for (const chunk of event.mediaChunks) {
if (chunk.mimeType.startsWith('audio/pcm;')) {
queueOutputAudio(chunk.mimeType, chunk.data, chunk.sequenceNumber);
}
}
}
if (event.interrupted) {
clearOutputAudioQueue();
}
}
);
}, [chatService, isActive, queueOutputAudio, clearOutputAudioQueue]);

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

The OutputStream subscription is only attached while isActive is true. Because isActive is set via React state inside handleAudioInputStart, any sendOutputStreamEvent(...) calls triggered synchronously by consumers in response to the activityStart InputStream event can occur before this effect re-runs, causing the first audio chunks to be missed. Prefer subscribing on mount (or whenever chatService exists) and gate playback via an isActiveRef/state check instead of conditionally subscribing.

Copilot uses AI. Check for mistakes.
Comment on lines +108 to +123
const { startAudioInput, stopAudioInput } = useAudioInput({
handleAudioInputData,
handleAudioInputStart,
handleAudioInputEnd,
});

const handleClick = useCallback(async () => {
if (disabled) {
return;
}
if (isActive) {
stopAudioInput();
} else {
await startAudioInput(true);
}
}, [disabled, isActive, startAudioInput, stopAudioInput]);

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

AlwaysOnVoiceButton does not stop microphone capture or clear queued audio on unmount. If the chat closes or audioStreaming is disabled while voice mode is active, the component can unmount without calling stopAudioInput(), leaving media tracks / AudioContext running. Add a useEffect cleanup that stops audio input and clears output (and, if needed, notifies onActiveChange(false)), ensuring resources are released reliably.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 23, 2026 14:57

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.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

tooltip={_(msg({ id: 'autopilot-chat.input.actions.dictate', message: `Dictate` }))}
overrideColor={isSpeechToTextActive ? 'var(--color-background)' : undefined}
data-testid="autopilot-chat-stt-button"
ariaLabel={_(msg({ id: 'autopilot-chat.input.actions.dictate', message: `Dictate` }))}

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

The dictate mic button is a toggle, but it doesn’t expose its pressed/toggled state to assistive tech. Consider adding aria-pressed={isSpeechToTextActive} (and similarly for the voice interaction toggle if applicable) so screen readers can announce the current state, not just the tooltip/label.

Suggested change
ariaLabel={_(msg({ id: 'autopilot-chat.input.actions.dictate', message: `Dictate` }))}
ariaLabel={_(msg({ id: 'autopilot-chat.input.actions.dictate', message: `Dictate` }))}
aria-pressed={isSpeechToTextActive}

Copilot uses AI. Check for mistakes.
Comment on lines +111 to +115
const { startAudioInput, stopAudioInput } = useAudioInput({
handleAudioInputData,
handleAudioInputStart,
handleAudioInputEnd,
});

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

useAudioInput does not auto-clean up on component unmount, so if the chat (or this button) unmounts while voice mode is active, the microphone stream/AudioContext/worker can remain alive. Add a cleanup effect in this component that calls stopAudioInput() (and clears output audio) on unmount / when disabling voice mode, and guard against state updates after unmount if needed.

Copilot uses AI. Check for mistakes.
Comment on lines +336 to +345
preventHover={true}
disabled={disableSubmit}
onClick={handleSubmitWithSttStop}
data-testid="autopilot-chat-submit-button"
ariaLabel={
waitingResponse
? _(msg({ id: 'autopilot-chat.input.actions.stop', message: `Stop` }))
: _(msg({ id: 'autopilot-chat.input.actions.send', message: `Send` }))
}
/>

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

The STT auto-stop logic is only applied to the submit button’s onClick, but keyboard submit (Enter in chat-input.tsx) still calls handleSubmit() directly, so dictation can continue after the prompt clears and re-populate the input. Also, when waitingResponse is true this same button acts as “Stop response”, but the wrapper still toggles STT, which is unexpected. Consider moving the “stop STT when sending a request” logic into a single shared submit path (e.g., inside handleSubmit when it actually sends) and skipping it for the stop-response path.

Copilot uses AI. Check for mistakes.
dsuresh-ap and others added 4 commits April 24, 2026 11:13
…R-9545]

Replaces the push-to-talk audio combo with two independently-gated voice
affordances:

- STT dictate button (mic icon, disabledFeatures.audio) — emits
  SpeechToTextToggle; consumers own the recognizer and stream transcripts
  back via setPrompt.
- Always-on voice interaction button (waveform icon,
  disabledFeatures.audioStreaming) — replaces send when input is empty,
  streams mic audio via InputStream / plays model audio via OutputStream.
  Input chrome collapses while active.

STT auto-stops on submit so the cleared input doesn't re-populate with
the prior transcript. The now-dead onPress/onRelease plumbing in
action-button is removed along with the deleted push-to-talk component.
ApChatShowcase gets toggles and fake consumers that demonstrate both
flows without needing a real recognizer or model.

Ports UiPath/apollo-design-system#5094 to the open-source chat package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the Format check on PR 571. Apply `biome format --write` to the
showcase additions so tabs/spaces match the repo style.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the Format check on PR 571. The previous format pass missed
apollo-react — only react-playground was covered. Apply
`biome format --write` to the two files touched in this port:
always-on-voice-button.tsx and chat-input-actions.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven review comments rolled into one commit:

- chat-input.tsx / chat-input-actions.tsx: move STT auto-stop into the
  shared `handleSubmit` so the Enter-key path gets the same auto-stop as
  the click path, and skip it entirely on the stop-response branch so
  hitting "Stop" doesn't also kill active dictation.
- chat-input.tsx / chat-input-actions.tsx: new `isInputEmpty` prop drives
  the send↔voice-interaction swap. Fixes an incorrect swap during
  attachment-loading / skeleton, where `disableSubmit` was true even
  though the user had real content to submit.
- chat-input-actions.tsx: seed `isSpeechToTextActive` from
  `chatService.isSpeechToTextActive` so a remount while STT is already
  active shows the active visual without waiting for the next event.
- always-on-voice-button.tsx: subscribe to OutputStream on mount and
  gate playback via an `isActiveRef` so audio chunks that arrive
  synchronously from the consumer's `activityStart` handler are not
  dropped. Add an unmount cleanup that stops mic capture and clears the
  output queue so the MediaStream / AudioContext is not leaked when the
  chat closes mid-voice.
- action-button.tsx + both toggles: thread an `ariaPressed` prop so
  screen readers announce the toggled state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 24, 2026 15:13
@dsuresh-ap dsuresh-ap force-pushed the feature/stt-voice-interaction-JAR-9545 branch from f544097 to ddb5dd4 Compare April 24, 2026 15:13

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.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

Comment on lines +3072 to +3073
* @property audioStreaming - Whether the chat has the always-on voice interaction button
* (requires the consumer to handle InputStream/OutputStream audio events)

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

Same issue for audioStreaming in this docs excerpt: it’s part of AutopilotChatDisabledFeatures, so audioStreaming: true disables the voice interaction button. Please adjust the description so consumers don't invert the boolean when configuring features.

Suggested change
* @property audioStreaming - Whether the chat has the always-on voice interaction button
* (requires the consumer to handle InputStream/OutputStream audio events)
* @property audioStreaming - Whether to disable the always-on voice interaction button
* (the feature requires the consumer to handle InputStream/OutputStream audio events)

Copilot uses AI. Check for mistakes.
* @property newChat - Whether the chat has the new chat button
* @property settings - Whether the chat has the settings button
* @property audio - Whether the chat has realtime audio enabled
* @property audio - Whether the chat has the STT dictate (mic) button

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

This JSDoc reads as if audio means the feature is present/enabled, but AutopilotChatDisabledFeatures is interpreted inversely throughout the UI (!disabledFeatures.audio). Please adjust the wording to reflect that this flag disables the STT dictate button to avoid consumer confusion.

Suggested change
* @property audio - Whether the chat has the STT dictate (mic) button
* @property audio - Whether to disable the STT dictate (mic) button

Copilot uses AI. Check for mistakes.
Comment on lines +423 to +424
* @property audioStreaming - Whether the chat has the always-on voice interaction button
* (requires the consumer to handle InputStream/OutputStream audio events)

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

Similar to audio, this doc says "Whether the chat has" the voice interaction button, but the property lives in AutopilotChatDisabledFeatures and is treated as a disable flag. Please update the wording to make it explicit that audioStreaming: true disables the voice interaction affordance.

Suggested change
* @property audioStreaming - Whether the chat has the always-on voice interaction button
* (requires the consumer to handle InputStream/OutputStream audio events)
* @property audioStreaming - Whether to disable the always-on voice interaction button
* (when enabled, it requires the consumer to handle InputStream/OutputStream audio events)

Copilot uses AI. Check for mistakes.
Comment on lines +559 to +562
// Mirror the publish back via setSpeechToTextState so the mic button stays in sync
// (publishSpeechToTextToggle already did this, but a real consumer would do it
// here after its recognizer actually started/failed).
if (isActive) {

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

The comment says the showcase "Mirror[s] the publish back via setSpeechToTextState" so the mic button stays in sync, but the handler never calls service.setSpeechToTextState(...). Either call setSpeechToTextState after the fake recognizer actually starts/stops, or update the comment to match the current behavior.

Copilot uses AI. Check for mistakes.
* @property newChat - Whether the chat has the new chat button
* @property settings - Whether the chat has the settings button
* @property audio - Whether the chat has realtime audio enabled
* @property audio - Whether the chat has the STT dictate (mic) button

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

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

In this AutopilotChatDisabledFeatures excerpt, the audio field description reads like an enable flag ("Whether the chat has...") but in practice audio: true disables the dictate button. Updating the wording here would make the docs match the API semantics.

Suggested change
* @property audio - Whether the chat has the STT dictate (mic) button
* @property audio - Whether to disable the STT dictate (mic) button

Copilot uses AI. Check for mistakes.
…ming

Addresses follow-up Copilot review comments: the `@property` JSDoc for
`audio` and `audioStreaming` read as "Whether the chat has..." but the
interface is `AutopilotChatDisabledFeatures` and the UI treats these
inversely (`!disabledFeatures.audio`). Reword to "Whether to disable..."
so consumers don't invert the boolean when configuring features.

Also fix the stale showcase comment claiming the fake STT consumer
mirrors the toggle via `setSpeechToTextState` — it doesn't; describe the
actual behavior and the failure-revert pattern a real consumer follows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dsuresh-ap dsuresh-ap merged commit 0f88acd into main Apr 24, 2026
39 checks passed
@dsuresh-ap dsuresh-ap deleted the feature/stt-voice-interaction-JAR-9545 branch April 24, 2026 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants