Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .eslintrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ overrides:
no-unused-vars: off
'@typescript-eslint/no-unused-vars':
- error
- argsIgnorePattern: ^_$
- argsIgnorePattern: ^_
varsIgnorePattern: ^_

no-empty-function: off
Expand Down
45 changes: 45 additions & 0 deletions .github/agents/polymiddleware-promoter.agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
name: Polymiddleware promoter
description: Upgrade middleware to polymiddleware
argument-hint: The existing middleware to upgrade
# tools: ['vscode', 'execute', 'read', 'agent', 'edit', 'search', 'web', 'todo'] # specify the tools this agent can use. If not set, all enabled tools are allowed.
---

<!-- Tip: Use /create-agent in chat to generate content with agent assistance -->

You are a developer upgrading middleware to polymiddleware.

Polymiddleware is newer, while middleware is older and should be upgraded.

Middleware and polymiddleware are pattern for plug-ins and our customization story. There are 2 sides: writing the middleware and using the middleware. Web Chat write and use the middleware. 3P developers write middleware and pass it to Web Chat.

Polymiddleware is a single middleware that process multiple types of middleware. Middleware is more like `request => (props => view) | undefined`, while polymiddleware is `init => (request => (props => view) | undefined) | undefined`.

The middleware philosophy can be found at https://npmjs.com/package/react-chain-of-responsibility.

When middleware receive a request, it decides if it want to process the request. If yes, it will return a React component. If no, it will pass it to the next middleware.

Definition of polymiddleware are at `packages/api-middleware/src/index.ts`.

Definition of middleware are scattered around but entrypoint at `packages/api/src/hooks/Composer.tsx`.

- You MUST upgrade all the usage of existing middleware to polymiddleware
- You MUST write a legacy bridge to convert existing middleware into polymiddleware, look at `packages/api/src/legacy`
- All tests MUST be visual regression tests, expectations MUST live inside the generated PNGs
- You MUST NOT update any existing PNGs, as it means breaking existing feature
- You MUST write migration tests: write a old middleware and pass it, it should render as expected because the code went through the new legacy bridge
- You MUST write polymiddleware test: write a new polymiddleware and pass it, it should render
- For each category of test, you MUST test it in 4 different way:
1. Add new UI that will process new type of requests
- You MUST verify existing middleware does not process that new type of request, only new polymiddleware does
2. Delete existing UI: request processed by existing middleware should no longer process
3. Replace UI that was processed by existing middleware, but now processed by a new middleware
4. Decorate existing UI but wrapping the result from existing middleware, commonly with a border component
- "request" vs. "props"
- Code processing the request MUST NOT call hooks
- Code processing the request decide to render a React component or not
- Code processing the props MUST render, minimally, `<Fragment />` or `null`, they are processed by React
- Request SHOULD contains information about "should render or not"
- Props SHOULD contains information about "how to render"
- You MUST NOT remove the existing middleware from `<Composer>`, however, print a deprecation warn-once, then bridge it to the polymiddleware
- You SHOULD NOT export the `<XXXProvider>`, `XXXProviderProps`, and `extractXXXEnhancer`
2 changes: 1 addition & 1 deletion .github/workflows/pull-request-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ jobs:
strategy:
matrix:
os:
- macos-latest
- macos-26
- ubuntu-latest
- windows-latest
runs-on: ${{ matrix.os }}
Expand Down
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
- Prefer uppercase for acronyms instead of Pascal case, e.g. `getURL()` over `getUrl()`
- The only exception is `id`, e.g. `getId()` over `getID()`
- Use fewer shorthands, only allow `min`, `max`, `num`
- Prefer ternary operator over one-liner `if` statement

### Design

Expand All @@ -35,6 +36,7 @@
### Typing

- TypeScript is best-effort checking, use `valibot` for strict type checking
- Use TypeScript CLI instead of `tsc`
- Use `valibot` for runtime type checker, never use `zod`
- Assume all externally exported functions will receive unsafe/invalid input, always check with `valibot`
- Avoid `any`
Expand Down Expand Up @@ -100,9 +102,15 @@ export { MyComponentPropsSchema, type MyComponentProps };
- Use `@testduet/given-when-then` package instead of xUnit style `describe`/`before`/`test`/`after`
- Prefer integration/end-to-end testing than unit testing
- Use as realistic setup as possible, such as using `msw` than mocking calls
- Use `emulateIncomingActivity` and `emulateOutgoingActivity` to emulate conversation

## PR instructions

- Run new test and all of them must be green
- Run `npm run precommit` to make sure it pass all linting process
- Add changelog entry to `CHANGELOG.md`, follow our existing format

## Code review

- Code should use as much immutable (via `Object.freeze()`) as possible, DO NOT trust `readonly`
- All inputs SHOULD be validated
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<!doctype html>
<!-- Tests legacy avatar middleware passes through to new polymiddleware via legacy bridge (add new) -->
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.3.1"
}
}
</script>
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat"></main>
<script type="module">
import { createElement, memo } from 'react';

run(async function () {
const {
testHelpers: { createDirectLineEmulator }
} = window;

const { directLine, store } = createDirectLineEmulator();

// GIVEN: A legacy avatar middleware that renders a custom avatar.
// The request processing (here, returning false/render function) is outside React lifecycle.
const avatarMiddleware =
() =>
_next =>
({ fromUser }) =>
() =>
createElement(
'div',
{
style: {
alignItems: 'center',
backgroundColor: fromUser ? 'Orange' : 'Purple',
borderRadius: '50%',
color: 'White',
display: 'flex',
fontSize: 12,
height: 40,
justifyContent: 'center',
width: 40
}
},
fromUser ? 'USR' : 'BOT'
);

WebChat.renderWebChat(
{
avatarMiddleware,
directLine,
store,
styleOptions: {
botAvatarInitials: 'WC',
userAvatarInitials: 'WW'
}
},
document.getElementById('webchat')
);

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity({ text: 'Hello from bot.', type: 'message' });
await directLine.emulateIncomingActivity({ from: { role: 'user' }, text: 'Hello from user.', type: 'message' });

await pageConditions.numActivitiesShown(2);
await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<!doctype html>
<!-- Tests legacy avatar middleware can decorate the default avatar by calling next and wrapping -->
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.3.1"
}
}
</script>
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat"></main>
<script type="module">
import { createElement } from 'react';

run(async function () {
const {
testHelpers: { createDirectLineEmulator }
} = window;

const { directLine, store } = createDirectLineEmulator();

// GIVEN: A legacy avatar middleware that calls next to get the default avatar renderer
// and wraps it in a decorative border.
const avatarMiddleware =
() =>
next =>
args => {
const defaultRenderer = next(args);

if (!defaultRenderer) {
// No default avatar configured; skip decoration.
return false;
}

// Decorate: wrap the default avatar with a colored ring.
return () =>
createElement(
'div',
{
style: {
borderRadius: '50%',
boxShadow: '0 0 0 3px Gold',
display: 'inline-block'
}
},
defaultRenderer()
);
};

WebChat.renderWebChat(
{
avatarMiddleware,
directLine,
store,
styleOptions: {
botAvatarInitials: 'WC',
userAvatarInitials: 'WW'
}
},
document.getElementById('webchat')
);

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity({ text: 'Hello from bot.', type: 'message' });
await directLine.emulateIncomingActivity({ from: { role: 'user' }, text: 'Hello from user.', type: 'message' });

await pageConditions.numActivitiesShown(2);
await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<!doctype html>
<!-- Tests legacy avatar middleware can delete the avatar by returning false (add new) -->
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.3.1"
}
}
</script>
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat"></main>
<script type="module">
run(async function () {
const {
testHelpers: { createDirectLineEmulator }
} = window;

const { directLine, store } = createDirectLineEmulator();

// GIVEN: A legacy avatar middleware that removes the avatar by returning false.
const avatarMiddleware = () => _next => _args => false;

WebChat.renderWebChat(
{
avatarMiddleware,
directLine,
store,
styleOptions: {
botAvatarInitials: 'WC',
userAvatarInitials: 'WW'
}
},
document.getElementById('webchat')
);

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity({ text: 'Hello from bot.', type: 'message' });
await directLine.emulateIncomingActivity({ from: { role: 'user' }, text: 'Hello from user.', type: 'message' });

await pageConditions.numActivitiesShown(2);
await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<!doctype html>
<!-- Tests legacy avatar middleware can replace the default avatar -->
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.3.1"
}
}
</script>
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat"></main>
<script type="module">
import { createElement } from 'react';

run(async function () {
const {
testHelpers: { createDirectLineEmulator }
} = window;

const { directLine, store } = createDirectLineEmulator();

// GIVEN: A legacy avatar middleware that replaces the default avatar regardless of next.
// "replace" means: ignore `next` entirely, always return own rendering function.
const avatarMiddleware =
() =>
_next =>
({ fromUser }) =>
() =>
createElement(
'div',
{
style: {
alignItems: 'center',
background: fromUser ? 'linear-gradient(135deg, #f093fb, #f5576c)' : 'linear-gradient(135deg, #4facfe, #00f2fe)',
borderRadius: 4,
color: 'White',
display: 'flex',
fontSize: 10,
fontWeight: 'bold',
height: 40,
justifyContent: 'center',
width: 40
}
},
fromUser ? 'ME' : 'AI'
);

WebChat.renderWebChat(
{
avatarMiddleware,
directLine,
store,
styleOptions: {
botAvatarInitials: 'WC',
userAvatarInitials: 'WW'
}
},
document.getElementById('webchat')
);

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity({ text: 'Hello from bot.', type: 'message' });
await directLine.emulateIncomingActivity({ from: { role: 'user' }, text: 'Hello from user.', type: 'message' });

await pageConditions.numActivitiesShown(2);
await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading