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
10 changes: 9 additions & 1 deletion .claude/skills/port-widget/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,15 @@ Known variants: `menuSelect` → `connectMenu`/`useMenu`.
- Keep `$$widgetType` aligned across flavors.
- Do not invent new Vue patterns; match `createWidgetMixin`, `createSuitMixin`, scoped slots, and `renderCompat`.
- Do not add memoization hooks in React unless an adjacent widget uses them for the same reason.
- `chat` is now available in UMD; no special exclusions apply.

### Cross-package API migration checks

- Update all affected layers in one pass when moving or splitting behavior: wrapper APIs, shared UI components, layout/type contracts, exports, and tests. Wrapper-only edits create cross-package type drift.
- If you remove a required prop or render path, either delete it end-to-end or make downstream contracts optional in the same change; partial migrations often fail during declaration builds.
- Before finishing, grep for legacy names (old props, options, class names, template keys, translations keys) to catch leftovers across flavors.
- Keep control contracts explicit and consistent when behavior is externally controlled (e.g., imperative handles or widget methods), so external integrations do not depend on internal layout details.
- Do not add ad-hoc top-level markdown change logs unless requested; keep source of truth in code, tests, and existing docs.
- Always run monorepo build validation (`yarn build --ignore='example*'`) after cross-package API changes; it catches declaration and export drift that local tests may miss.

## References

Expand Down
35 changes: 0 additions & 35 deletions packages/instantsearch-ui-components/src/components/chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,12 @@ import { createChatMessagesComponent } from './ChatMessages';
import { createChatOverlayLayoutComponent } from './ChatOverlayLayout';
import { createChatPromptComponent } from './ChatPrompt';
import { createChatPromptSuggestionsComponent } from './ChatPromptSuggestions';
import { createChatToggleButtonComponent } from './ChatToggleButton';

import type { Renderer, ComponentProps } from '../../types';
import type { ChatHeaderProps, ChatHeaderOwnProps } from './ChatHeader';
import type { ChatMessagesProps } from './ChatMessages';
import type { ChatPromptProps, ChatPromptOwnProps } from './ChatPrompt';
import type { ChatPromptSuggestionsOwnProps } from './ChatPromptSuggestions';
import type {
ChatToggleButtonOwnProps,
ChatToggleButtonProps,
} from './ChatToggleButton';
import type { ChatLayoutOwnProps } from './types';

export type ChatClassNames = {
Expand All @@ -26,7 +21,6 @@ export type ChatClassNames = {
messages?: ChatMessagesProps['classNames'];
message?: ChatMessagesProps['messageClassNames'];
prompt?: ChatPromptProps['classNames'];
toggleButton?: ChatToggleButtonProps['classNames'];
suggestions?: ChatPromptSuggestionsOwnProps['classNames'];
};

Expand All @@ -43,10 +37,6 @@ export type ChatProps = Omit<ComponentProps<'div'>, 'onError' | 'title'> & {
* Props for the ChatHeader component.
*/
headerProps: ChatHeaderProps;
/*
* Props for the ChatToggleButton component.
*/
toggleButtonProps: ChatToggleButtonProps;
/*
* Props for the ChatMessages component.
*/
Expand Down Expand Up @@ -75,10 +65,6 @@ export type ChatProps = Omit<ComponentProps<'div'>, 'onError' | 'title'> & {
* Optional prompt component for the chat
*/
promptComponent?: (props: ChatPromptOwnProps) => JSX.Element;
/**
* Optional toggle button component for the chat
*/
toggleButtonComponent?: (props: ChatToggleButtonOwnProps) => JSX.Element;
/**
* Optional suggestions component for the chat
*/
Expand Down Expand Up @@ -107,10 +93,6 @@ export type ChatProps = Omit<ComponentProps<'div'>, 'onError' | 'title'> & {
};

export function createChatComponent({ createElement, Fragment }: Renderer) {
const ChatToggleButton = createChatToggleButtonComponent({
createElement,
Fragment,
});
const ChatHeader = createChatHeaderComponent({ createElement, Fragment });
const ChatMessages = createChatMessagesComponent({ createElement, Fragment });
const ChatPrompt = createChatPromptComponent({ createElement, Fragment });
Expand All @@ -128,13 +110,11 @@ export function createChatComponent({ createElement, Fragment }: Renderer) {
open,
maximized = false,
headerProps,
toggleButtonProps,
messagesProps,
suggestionsProps,
promptProps = {},
headerComponent: HeaderComponent,
promptComponent: PromptComponent,
toggleButtonComponent: ToggleButtonComponent,
suggestionsComponent: SuggestionsComponent,
layoutComponent: LayoutComponent = OverlayLayout,
classNames = {},
Expand Down Expand Up @@ -172,20 +152,6 @@ export function createChatComponent({ createElement, Fragment }: Renderer) {
classNames: classNames.prompt,
});

const toggleButtonComponent = createElement(
ToggleButtonComponent || ChatToggleButton,
{
...toggleButtonProps,
classNames: classNames.toggleButton,
onClick: () => {
toggleButtonProps.onClick?.();
if (!open) {
promptProps.promptRef?.current?.focus();
}
},
}
);

return (
<LayoutComponent
{...props}
Expand All @@ -194,7 +160,6 @@ export function createChatComponent({ createElement, Fragment }: Renderer) {
headerComponent={headerComponent}
messagesComponent={messagesComponent}
promptComponent={promptComponent}
toggleButtonComponent={toggleButtonComponent}
classNames={{ root: classNames.root, container: classNames.container }}
className={className}
messages={messagesProps.messages}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe('Chat', () => {
<div>
<div
class="ais-Chat ais-ChatOverlayLayout"
togglebuttonprops="[object Object]"
>
<div
Comment on lines 40 to 45
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

The snapshot now includes the togglebuttonprops="[object Object]" attribute on the root element, which indicates toggleButtonProps is still being passed through and spread onto the DOM. Since the toggle button API was removed, update the test setup (and the component usage) to stop passing toggleButtonProps so it doesn’t leak unknown attributes into the DOM.

Copilot uses AI. Check for mistakes.
class="ais-Chat-container ais-Chat-container--open"
Expand Down Expand Up @@ -215,25 +216,7 @@ describe('Chat', () => {
</div>
<div
class="ais-Chat-toggleButtonWrapper"
>
<button
class="ais-Button ais-Button--primary ais-Button--md ais-Button--icon-only ais-ChatToggleButton ais-ChatToggleButton--open"
type="button"
>
<svg
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m18 15-6-6-6 6"
/>
</svg>
</button>
</div>
/>
</div>
</div>
`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ export type ChatLayoutOwnProps<
headerComponent: JSX.Element;
messagesComponent: JSX.Element;
promptComponent: JSX.Element;
toggleButtonComponent: JSX.Element;
toggleButtonComponent?: JSX.Element;
classNames?: { root?: string | string[]; container?: string | string[] };
isClearing?: boolean;
clearMessages?: () => void;
Expand All @@ -475,7 +475,10 @@ export type ChatLayoutOwnProps<
tools: ClientSideTools;
} & Pick<ChatState<TMessage>, 'messages'> &
Partial<Pick<ChatState<TMessage>, 'status'>> &
Pick<AbstractChat<TMessage>, 'sendMessage' | 'regenerate' | 'stop' | 'error'> &
Pick<
AbstractChat<TMessage>,
'sendMessage' | 'regenerate' | 'stop' | 'error'
> &
ComponentProps<'div'>;

export type ClientSideToolComponentProps = {
Expand Down
15 changes: 8 additions & 7 deletions packages/instantsearch.js/src/__tests__/common-widgets.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -666,14 +666,15 @@ const testSetups: TestSetupsMap<TestSuites, 'javascript'> = {
);
}

const chatWidget = chat({
container: document.body.appendChild(document.createElement('div')),
disableTriggerValidation: true,
...chatWidgetParams,
});
globalThis.__chatTestSetOpen = chatWidget.setOpen.bind(chatWidget);

instantsearch(instantSearchOptions)
.addWidgets([
...refinementsWidgets,
chat({
container: document.body.appendChild(document.createElement('div')),
...chatWidgetParams,
}),
])
.addWidgets([...refinementsWidgets, chatWidget])
.on('error', () => {
/*
* prevent rethrowing InstantSearch errors, so tests can be asserted.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('connectChat', () => {
const makeWidget = connectChat(renderFn);
const widget = makeWidget({
...(!('agentId' in widgetParams) ? { agentId: 'agentId' } : {}),
disableTriggerValidation: true,
...widgetParams,
});

Expand Down Expand Up @@ -237,7 +238,9 @@ describe('connectChat', () => {

expect(renderFn).toHaveBeenCalledTimes(1);
expect(renderFn).toHaveBeenLastCalledWith(
expect.objectContaining({ widgetParams: { agentId: 'agentId' } }),
expect.objectContaining({
widgetParams: expect.objectContaining({ agentId: 'agentId' }),
}),
true
);

Expand All @@ -246,7 +249,9 @@ describe('connectChat', () => {

expect(renderFn).toHaveBeenCalledTimes(2);
expect(renderFn).toHaveBeenLastCalledWith(
expect.objectContaining({ widgetParams: { agentId: 'agentId' } }),
expect.objectContaining({
widgetParams: expect.objectContaining({ agentId: 'agentId' }),
}),
false
);
});
Expand All @@ -255,7 +260,10 @@ describe('connectChat', () => {
it('calls the unmount function', () => {
const unmountFn = jest.fn();
const makeWidget = connectChat(() => {}, unmountFn);
const widget = makeWidget({ agentId: 'agentId' });
const widget = makeWidget({
agentId: 'agentId',
disableTriggerValidation: true,
});

const helper = algoliasearchHelper(createSearchClient(), '', {});

Expand Down Expand Up @@ -512,7 +520,7 @@ data: [DONE]`,
it('throws error when neither agentId nor transport is provided', () => {
const renderFn = jest.fn();
const makeWidget = connectChat(renderFn);
const widget = makeWidget({});
const widget = makeWidget({ disableTriggerValidation: true });

const helper = algoliasearchHelper(createSearchClient(), '', {});

Expand All @@ -536,5 +544,4 @@ data: [DONE]`,
);
});
});

});
41 changes: 39 additions & 2 deletions packages/instantsearch.js/src/connectors/chat/connectChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ export type ChatConnectorParams<TUiMessage extends UIMessage = UIMessage> = (
| { chat: Chat<TUiMessage> }
| ChatInit<TUiMessage>
) & {
/**
* Disable validation that requires either a dedicated trigger or AI mode.
*/
disableTriggerValidation?: boolean;
/**
* Whether to resume an ongoing chat generation stream.
*/
Expand Down Expand Up @@ -260,6 +264,7 @@ export default (function connectChat<TWidgetParams extends UnknownWidgetParams>(
resume = false,
tools = {},
type = 'chat',
disableTriggerValidation = false,
...options
} = widgetParams || {};

Expand All @@ -273,6 +278,7 @@ export default (function connectChat<TWidgetParams extends UnknownWidgetParams>(
let focusInput: ChatRenderState<TUiMessage>['focusInput'];
let setIsClearing: (value: boolean) => void;
let setFeedbackState: (messageId: string, state: 'sending' | 0 | 1) => void;
let hasValidatedEntryPoints = false;

const agentId = 'agentId' in options ? options.agentId : undefined;
let feedbackState: ChatRenderState<TUiMessage>['feedbackState'] = {};
Expand Down Expand Up @@ -332,6 +338,34 @@ export default (function connectChat<TWidgetParams extends UnknownWidgetParams>(
setIsClearing(false);
};

const validateEntryPoints = (instantSearchInstance: InstantSearch) => {
if (disableTriggerValidation || hasValidatedEntryPoints) {
return;
}

// mainIndex may be absent in test environments or when called from
// getWidgetRenderState before a full init has taken place.
if (!instantSearchInstance.mainIndex) {
return;
}

const widgets = instantSearchInstance.mainIndex.getWidgets() as Array<{
opensChat?: boolean;
}>;

const hasEntryPoint = widgets.some((w) => w.opensChat === true);
Comment on lines +352 to +356
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

should use walkIndex


if (!hasEntryPoint) {
throw new Error(
withUsage(
'The `chat` widget requires a way to open the chat. Add a `chatTrigger` widget or enable AI mode on an input widget. Use `disableTriggerValidation: true` to opt out.'
)
);
}

hasValidatedEntryPoints = true;
};

const makeChatInstance = (instantSearchInstance: InstantSearch) => {
let transport;
const [appId, apiKey] = getAppIdAndApiKey(instantSearchInstance.client);
Expand Down Expand Up @@ -470,6 +504,8 @@ export default (function connectChat<TWidgetParams extends UnknownWidgetParams>(
init(initOptions) {
const { instantSearchInstance } = initOptions;

validateEntryPoints(instantSearchInstance);

_chatInstance = makeChatInstance(instantSearchInstance);

const render = () => {
Expand Down Expand Up @@ -506,8 +542,7 @@ export default (function connectChat<TWidgetParams extends UnknownWidgetParams>(
render();
};

const feedback =
'feedback' in options ? options.feedback : undefined;
const feedback = 'feedback' in options ? options.feedback : undefined;
if (agentId && feedback) {
const [appId, apiKey] = getAppIdAndApiKey(
initOptions.instantSearchInstance.client
Expand Down Expand Up @@ -557,6 +592,8 @@ export default (function connectChat<TWidgetParams extends UnknownWidgetParams>(
},

render(renderOptions) {
validateEntryPoints(renderOptions.instantSearchInstance);

renderFn(
{
...this.getWidgetRenderState(renderOptions),
Expand Down
Loading
Loading