Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ Breaking changes in this release:
- Breakpoint: open <kbd>F12</kbd>, select the subject in Element pane, type `$0.webChat.breakpoint.incomingActivity`
- The `botframework-webchat` package now uses CSS modules for styling purposes, in PR [#5666](https://github.com/microsoft/BotFramework-WebChat/pull/5666), in PR [#5677](https://github.com/microsoft/BotFramework-WebChat/pull/5677) by [@OEvgeny](https://github.com/OEvgeny)
- 👷🏻 Added `npm run build-browser` script for building test harness package only, in PR [#5667](https://github.com/microsoft/BotFramework-WebChat/pull/5667), by [@compulim](https://github.com/compulim)
- Added pull-based capabilities system for dynamically discovering adapter capabilities at runtime, in PR [#5679](https://github.com/microsoft/BotFramework-WebChat/pull/5679), by [@pranavjoshi001](https://github.com/pranavjoshi001)

### Changed

Expand Down
109 changes: 109 additions & 0 deletions __tests__/html2/hooks/useCapabilities.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
</head>
<body>
<main id="webchat"></main>
<script type="importmap">
{
"imports": {
"@testduet/wait-for": "https://unpkg.com/@testduet/wait-for@main/dist/wait-for.mjs",
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
"botframework-webchat/component": "/__dist__/packages/bundle/static/botframework-webchat/component.js",
"botframework-webchat/hook": "/__dist__/packages/bundle/static/botframework-webchat/hook.js",
"react": "https://esm.sh/react@18",
"react-dom": "https://esm.sh/react-dom@18",
"react-dom/": "https://esm.sh/react-dom@18/"
}
}
</script>
<script type="module">
import '/test-harness.mjs';
import '/test-page-object.mjs';

import { waitFor } from '@testduet/wait-for';
import { createStoreWithOptions, testIds } from 'botframework-webchat';
import { useCapabilities } from 'botframework-webchat/hook';
import createRenderHook from '/assets/esm/createRenderHook.js';

const { createDirectLineEmulator } = window.testHelpers;

window.WebChat = { createStoreWithOptions, testIds };

run(async function () {
// TEST 1: Initial fetch on mount - capabilities should be fetched when directLine is available
const { directLine, store } = createDirectLineEmulator();

// Set initial capability BEFORE mount (simulating adapter already having capability)
directLine.setCapability('getVoiceConfiguration', { voice: 'en-US', speed: 1.0 }, { emitEvent: false });

const renderHook = createRenderHook(
document.getElementById('webchat'),
{ directLine, store },
{ renderWebChat: true }
);

await renderHook();
await pageConditions.uiConnected();

// Get initial voiceConfiguration using selector
const initialVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));

expect(initialVoiceConfig).toEqual({ voice: 'en-US', speed: 1.0 });

// TEST 2: Regular activity should NOT trigger capability re-calculation
// Store reference to current voiceConfiguration
const preActivityVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));

// Send a regular message (not capabilitiesChanged event)
await directLine.emulateIncomingActivity({
type: 'message',
text: 'Hello! This is a regular message.',
from: { id: 'bot', role: 'bot' }
});

// Wait for activity to be processed
await new Promise(resolve => setTimeout(resolve, 200));

// Get voiceConfiguration after regular activity
const postActivityVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));

// Reference should be the same (no re-calculation for regular activities)
expect(postActivityVoiceConfig).toBe(preActivityVoiceConfig);

// TEST 3: capabilitiesChanged event SHOULD trigger re-calculation
const preChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));

// Update capability and emit event
directLine.setCapability('getVoiceConfiguration', { voice: 'en-GB', speed: 1.5 }, { emitEvent: true });

// Wait for event to be processed
await waitFor(async () => {
const voiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));
return voiceConfig?.voice === 'en-GB';
}, { timeout: 2000 });

const postChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));

expect(postChangeVoiceConfig).toEqual({ voice: 'en-GB', speed: 1.5 });
expect(postChangeVoiceConfig).not.toBe(preChangeVoiceConfig);

// TEST 4: Same value should reuse reference (shallow equality check)
const preNoChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));

// Set same value and emit event
directLine.setCapability('getVoiceConfiguration', { voice: 'en-GB', speed: 1.5 }, { emitEvent: true });

// Wait for event to be processed
await new Promise(resolve => setTimeout(resolve, 200));

const postNoChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));

// Reference should be the same when values are equal
expect(postNoChangeVoiceConfig).toBe(preNoChangeVoiceConfig);
expect(postNoChangeVoiceConfig).toEqual({ voice: 'en-GB', speed: 1.5 });
});
</script>
</body>
</html>
85 changes: 85 additions & 0 deletions docs/CAPABILITIES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Capabilities

Web Chat supports dynamic capability discovery from adapters. Capabilities allow adapters to expose configuration values that Web Chat components can consume and react to changes.

## Using the hook

Use the `useCapabilities` hook with a selector to access specific capabilities:

```js
import { useCapabilities } from 'botframework-webchat/hook';

// Get voice configuration
const voiceConfig = useCapabilities(caps => caps.voiceConfiguration);

if (voiceConfig) {
console.log(`Sample rate: ${voiceConfig.sampleRate}`);
console.log(`Chunk interval: ${voiceConfig.chunkIntervalMs}ms`);
}
```

> **Note:** A selector function is required. This ensures components only re-render when their specific capability changes.

## Available capabilities

| Capability | Type | Description |
| -------------------- | -------------------------------------------------- | ----------------------------------- |
| `voiceConfiguration` | `{ chunkIntervalMs: number, sampleRate: number }` | Audio settings for Speech-to-Speech |

## How it works

1. **Initial fetch** - When WebChat mounts, it checks if the adapter exposes capability getter functions and retrieves initial values
2. **Event-driven updates** - When the adapter emits a `capabilitiesChanged` event, WebChat re-fetches all capabilities from the adapter
3. **Optimized re-renders** - Only components consuming changed capabilities will re-render

## For adapter implementers

To expose capabilities from your adapter:

### 1. Implement getter functions

```js
const adapter = {
// ... other adapter methods

getVoiceConfiguration() {
return {
sampleRate: 16000,
chunkIntervalMs: 100
};
}
};
```

### 2. Emit change events

When capability values change, emit a `capabilitiesChanged` event activity:

```js
// When configuration changes, emit the nudge event
adapter.activity$.next({
type: 'event',
name: 'capabilitiesChanged',
from: { id: 'bot', role: 'bot' }
});
```

WebChat will then call all capability getter functions and update consumers if values changed.

## Adding new capabilities

To add a new capability:

1. Add the type to `Capabilities` in `packages/api/src/providers/Capabilities/types/Capabilities.ts`
2. Add the registry entry in `packages/api/src/providers/Capabilities/private/capabilityRegistry.ts`
3. Implement the getter in your adapter (e.g., `getMyCapability()`)

The registry maps capability keys to getter function names:

```js
// capabilityRegistry.ts
{
key: 'voiceConfiguration',
getterName: 'getVoiceConfiguration'
}
```
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@
"react-redux": "7.2.9",
"redux": "5.0.1",
"simple-update-in": "2.2.0",
"use-reduce-memo": "0.1.0",
"use-ref-from": "0.1.0",
"valibot": "1.2.0"
},
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/boot/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {
useAvatarForUser,
useBuildRenderActivityCallback,
useByteFormatter,
useCapabilities,
useConnectivityStatus,
useCreateActivityRenderer,
useCreateActivityStatusRenderer,
Expand Down
35 changes: 19 additions & 16 deletions packages/api/src/hooks/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import ActivityListenerComposer from '../providers/ActivityListener/ActivityList
import ActivitySendStatusComposer from '../providers/ActivitySendStatus/ActivitySendStatusComposer';
import ActivitySendStatusTelemetryComposer from '../providers/ActivitySendStatusTelemetry/ActivitySendStatusTelemetryComposer';
import ActivityTypingComposer from '../providers/ActivityTyping/ActivityTypingComposer';
import CapabilitiesComposer from '../providers/Capabilities/CapabilitiesComposer';
import GroupActivitiesComposer from '../providers/GroupActivities/GroupActivitiesComposer';
import PonyfillComposer from '../providers/Ponyfill/PonyfillComposer';
import StyleOptionsComposer from '../providers/StyleOptions/StyleOptionsComposer';
Expand Down Expand Up @@ -592,22 +593,24 @@ const ComposerCore = ({

return (
<WebChatAPIContext.Provider value={context}>
<ActivityListenerComposer>
<ActivitySendStatusComposer>
<ActivityTypingComposer>
<SendBoxMiddlewareProvider middleware={sendBoxMiddleware || EMPTY_ARRAY}>
<SendBoxToolbarMiddlewareProvider middleware={sendBoxToolbarMiddleware || EMPTY_ARRAY}>
<GroupActivitiesComposer groupActivitiesMiddleware={singleToArray(groupActivitiesMiddleware)}>
<PolymiddlewareComposer polymiddleware={polymiddleware}>
{typeof children === 'function' ? children(context) : children}
</PolymiddlewareComposer>
</GroupActivitiesComposer>
<ActivitySendStatusTelemetryComposer />
</SendBoxToolbarMiddlewareProvider>
</SendBoxMiddlewareProvider>
</ActivityTypingComposer>
</ActivitySendStatusComposer>
</ActivityListenerComposer>
<CapabilitiesComposer>
<ActivityListenerComposer>
<ActivitySendStatusComposer>
<ActivityTypingComposer>
<SendBoxMiddlewareProvider middleware={sendBoxMiddleware || EMPTY_ARRAY}>
<SendBoxToolbarMiddlewareProvider middleware={sendBoxToolbarMiddleware || EMPTY_ARRAY}>
<GroupActivitiesComposer groupActivitiesMiddleware={singleToArray(groupActivitiesMiddleware)}>
<PolymiddlewareComposer polymiddleware={polymiddleware}>
{typeof children === 'function' ? children(context) : children}
</PolymiddlewareComposer>
</GroupActivitiesComposer>
<ActivitySendStatusTelemetryComposer />
</SendBoxToolbarMiddlewareProvider>
</SendBoxMiddlewareProvider>
</ActivityTypingComposer>
</ActivitySendStatusComposer>
</ActivityListenerComposer>
</CapabilitiesComposer>
{onTelemetry && <Tracker />}
</WebChatAPIContext.Provider>
);
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import useCapabilities from '../providers/Capabilities/useCapabilities';
import useGroupActivities from '../providers/GroupActivities/useGroupActivities';
import useGroupActivitiesByName from '../providers/GroupActivities/useGroupActivitiesByName';
import useActiveTyping from './useActiveTyping';
Expand Down Expand Up @@ -83,6 +84,7 @@ export {
useAvatarForBot,
useAvatarForUser,
useByteFormatter,
useCapabilities,
useConnectivityStatus,
useCreateActivityRenderer,
useCreateActivityStatusRenderer,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React, { memo, useCallback, useMemo, type ReactNode } from 'react';
import { useReduceMemo } from 'use-reduce-memo';
import type { WebChatActivity } from 'botframework-webchat-core';
import { literal, object, safeParse } from 'valibot';

import useActivities from '../../hooks/useActivities';
import useWebChatAPIContext from '../../hooks/internal/useWebChatAPIContext';
import CapabilitiesContext from './private/Context';
import fetchCapabilitiesFromAdapter from './private/fetchCapabilitiesFromAdapter';
import type { Capabilities } from './types/Capabilities';

type Props = Readonly<{ children?: ReactNode | undefined }>;

const EMPTY_CAPABILITIES: Capabilities = Object.freeze({});

// Synthetic marker to trigger initial fetch - must be a stable reference
const INIT_MARKER = Object.freeze({ type: 'capabilities:init' as const });
type InitMarker = typeof INIT_MARKER;
type ReducerInput = WebChatActivity | InitMarker;

const CapabilitiesChangedEventSchema = object({
type: literal('event'),
name: literal('capabilitiesChanged')
});

const isInitMarker = (item: ReducerInput): item is InitMarker => item === INIT_MARKER;

const isCapabilitiesChangedEvent = (activity: ReducerInput): boolean =>
safeParse(CapabilitiesChangedEventSchema, activity).success;

/**
* Composer that derives capabilities from the adapter using a pure derivation pattern.
*
* Design principles:
* 1. Initial fetch: Pulls capabilities from adapter on mount via synthetic init marker
* 2. Event-driven updates: Re-fetches only when 'capabilitiesChanged' event is detected
* 3. Stable references: Individual capability objects maintain reference equality if unchanged
* - This ensures consumers using selectors only re-render when their capability changes
*/
const CapabilitiesComposer = memo(({ children }: Props) => {
const [activities] = useActivities();
const { directLine } = useWebChatAPIContext();

const activitiesWithInit = useMemo<readonly ReducerInput[]>(
() => Object.freeze([INIT_MARKER, ...activities]),
[activities]
);

// TODO: [P1] update to use EventTarget than activity$.
const capabilities = useReduceMemo(
activitiesWithInit,
useCallback(
(prevCapabilities: Capabilities, item: ReducerInput): Capabilities => {
const shouldFetch = isInitMarker(item) || isCapabilitiesChangedEvent(item);

if (!shouldFetch) {
return prevCapabilities;
}

const { capabilities: newCapabilities, hasChanged } = fetchCapabilitiesFromAdapter(
directLine,
prevCapabilities
);

return hasChanged ? newCapabilities : prevCapabilities;
},
[directLine]
),
EMPTY_CAPABILITIES
);

const contextValue = useMemo(() => Object.freeze({ capabilities }), [capabilities]);

return <CapabilitiesContext.Provider value={contextValue}>{children}</CapabilitiesContext.Provider>;
});

CapabilitiesComposer.displayName = 'CapabilitiesComposer';

export default CapabilitiesComposer;
13 changes: 13 additions & 0 deletions packages/api/src/providers/Capabilities/private/Context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createContext } from 'react';
import type { Capabilities } from '../types/Capabilities';

type CapabilitiesContextType = Readonly<{
capabilities: Capabilities;
}>;

const CapabilitiesContext = createContext<CapabilitiesContextType | undefined>(undefined);

CapabilitiesContext.displayName = 'CapabilitiesContext';

export default CapabilitiesContext;
export type { CapabilitiesContextType };
Loading
Loading