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
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,20 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import React from 'react';

import { AutopilotChatEvent } from '../../service';
import { useChatService } from '../providers/chat-service.provider';
import { DEFAULT_LOADING_MESSAGE_KEY, useLoading } from '../providers/loading-provider';

/** Placeholder the service stores for "use the localized default". */
export const DEFAULT_LOADING_MESSAGE_KEY = 'autopilot-chat-generating-response';
export const DEFAULT_LOADING_MESSAGE_DURATION = 5000;
export {
DEFAULT_LOADING_MESSAGE_DURATION,
DEFAULT_LOADING_MESSAGE_KEY,
} from '../providers/loading-provider';

/**
* The loading messages to rotate while a reply is pending and how long each shows: the service's
* defaults (`setDefaultLoadingMessages`), replaced by a single message from `setLoadingMessage`.
* The loading messages to rotate while a reply is pending, how long each shows, and how to
* display one (the default key becomes the localized "Generating response...").
*/
export function useLoadingMessages() {
const { _ } = useLingui();
const chatService = useChatService();
const [messages, setMessages] = React.useState<string[]>(
() => chatService.getDefaultLoadingMessages() ?? [DEFAULT_LOADING_MESSAGE_KEY]
);
const [duration, setDuration] = React.useState(
() => chatService.getLoadingMessageDuration() ?? DEFAULT_LOADING_MESSAGE_DURATION
);

React.useEffect(() => {
if (!chatService) {
return;
}

const unsubscribes = [
chatService.on(
AutopilotChatEvent.SetDefaultLoadingMessages,
(data: { messages: string[]; duration?: number }) => {
setMessages(data.messages);
setDuration(data.duration ?? DEFAULT_LOADING_MESSAGE_DURATION);
}
),
chatService.on(AutopilotChatEvent.SetLoadingMessage, (message: string) =>
setMessages([message])
),
];

return () => {
for (const unsubscribe of unsubscribes) unsubscribe();
};
}, [chatService]);
const { loadingMessages, loadingMessageDuration } = useLoading();

const translate = React.useCallback(
(message: string) =>
Expand All @@ -59,5 +30,5 @@ export function useLoadingMessages() {
[_]
);

return { messages, duration, translate };
return { messages: loadingMessages, duration: loadingMessageDuration, translate };
}
143 changes: 143 additions & 0 deletions packages/apollo-react/src/chat/headless/hooks/use-suggestions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { act, render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import {
type AutopilotChatConfiguration,
AutopilotChatEvent,
AutopilotChatInternalEvent,
type AutopilotChatMessage,
AutopilotChatService,
} from '../../service';
import { AutopilotChatServiceProvider } from '../providers/chat-service.provider';
import { AutopilotChatStateProvider, useChatState } from '../providers/chat-state-provider';
import { useConversationSuggestions, useSuggestionSelect } from './use-suggestions';

let instanceCounter = 0;
let select: ReturnType<typeof useSuggestionSelect> | undefined;
let conversation: ReturnType<typeof useConversationSuggestions> | undefined;
let state: ReturnType<typeof useChatState> | undefined;
let shown: AutopilotChatMessage[] | undefined;

function Probe() {
select = useSuggestionSelect();
conversation = useConversationSuggestions(shown);
state = useChatState();
return null;
}

function setup(config: Partial<AutopilotChatConfiguration> = {}) {
const service = AutopilotChatService.Instantiate({
instanceName: `suggestions-${++instanceCounter}`,
config,
});
const view = render(
<AutopilotChatServiceProvider chatServiceInstance={service}>
<AutopilotChatStateProvider>
<Probe />
</AutopilotChatStateProvider>
</AutopilotChatServiceProvider>
);
const rerender = () =>
view.rerender(
<AutopilotChatServiceProvider chatServiceInstance={service}>
<AutopilotChatStateProvider>
<Probe />
</AutopilotChatStateProvider>
</AutopilotChatServiceProvider>
);
return { service, rerender };
}

const reply = (id: string) => ({
id,
content: id,
widget: 'apollo-markdown-renderer',
created_at: '',
});
const suggestion = { label: 'Overdue', prompt: 'Which are overdue?' };

describe('useSuggestionSelect', () => {
it('sends with sendOnClick, else fills the composer, and focuses it either way', async () => {
const { service } = setup();
const events: string[] = [];
service.on(AutopilotChatEvent.Request, () => events.push('request'));
service.on(AutopilotChatEvent.SetPrompt, (prompt) => events.push(`prompt:${prompt}`));
service.__internalService__.on(AutopilotChatInternalEvent.SetInputFocused, () =>
events.push('focus')
);

act(() => select?.(suggestion, false));
expect(events).toEqual(['prompt:Which are overdue?', 'focus']);

events.length = 0;
act(() => select?.(suggestion, true));
await act(async () => {});
expect(events).toContain('request');
expect(events).toContain('focus');
expect(service.getConversation().at(-1)?.content).toBe('Which are overdue?');
});
});

describe('conversation suggestions', () => {
it('belong to the user turn they were set in, replies included', async () => {
const { service, rerender } = setup();
// As a host would: set from its own Request handler, before the reply arrives.
service.on(AutopilotChatEvent.Request, () => service.setSuggestions([suggestion]));
await act(async () => service.sendRequest({ content: 'q1' }));
act(() => service.sendResponse(reply('r1')));
shown = service.getConversation();
rerender();
expect(conversation?.attached).toBe(true);

act(() => service.sendResponse(reply('r2')));
shown = service.getConversation();
rerender();
expect(conversation?.attached).toBe(true);

// A later user turn the service did not clear them for (restored conversation) leaves them.
act(() => service.setConversation([...service.getConversation()]));
shown = [...service.getConversation(), { ...reply('u2'), role: 'user' } as never];
rerender();
expect(conversation?.attached).toBe(false);
});

it('fall back to the first-run sendOnClick, as Material does', () => {
const { service } = setup({
firstRunExperience: { title: 'Hi', description: '', sendOnClick: true },
});

act(() => service.setSuggestions([suggestion]));
expect(conversation?.sendOnClick).toBe(true);

act(() => service.setSuggestions([suggestion], false));
expect(conversation?.sendOnClick).toBe(false);
});

it('are cleared by the service on a request, and by the provider on a new chat', () => {
const { service } = setup();
act(() => service.setSuggestions([suggestion]));
act(() => service.sendRequest({ content: 'next' }));
expect(conversation?.suggestions).toEqual([]);

act(() => service.setSuggestions([suggestion]));
act(() => service.newChat());
act(() => service.setConversation([reply('restored') as never]));
expect(conversation?.suggestions).toEqual([]);
});
});

describe('firstRunVisible', () => {
it('shows a first run with content while the conversation is empty', () => {
const { service } = setup({ firstRunExperience: { title: 'Welcome', description: '' } });
expect(state?.firstRunVisible).toBe(true);

act(() => state?.setHasMessages(true));
expect(state?.firstRunVisible).toBe(false);

act(() => {
state?.setHasMessages(false);
service.setFirstRunExperience({ title: '', description: '' });
});
expect(state?.firstRunVisible).toBe(false);
});
});
50 changes: 50 additions & 0 deletions packages/apollo-react/src/chat/headless/hooks/use-suggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';

import {
AutopilotChatInternalEvent,
type AutopilotChatMessage,
type AutopilotChatSuggestion,
} from '../../service';
import { useChatService } from '../providers/chat-service.provider';
import { countUserTurns, useChatState } from '../providers/chat-state-provider';

/**
* Picking a suggestion, as both renderers do: with `sendOnClick` its prompt is sent through the
* `Request` intercept, otherwise it fills the composer; either way the composer takes focus.
*/
export function useSuggestionSelect() {
const chatService = useChatService();

return React.useCallback(
(suggestion: AutopilotChatSuggestion, sendOnClick: boolean) => {
if (sendOnClick) {
chatService.sendRequest({ content: suggestion.prompt });
} else {
chatService.setPrompt(suggestion.prompt);
}
chatService.__internalService__.publish(AutopilotChatInternalEvent.SetInputFocused, true);
},
[chatService]
);
}

/**
* The suggestions to show after the conversation, and whether picking one sends it. `attached` is
* whether they still belong to the conversation's latest user turn, given the `messages` a
* renderer shows: replies to that turn keep them, the next user message leaves them behind (the
* service also clears them on every request).
*/
export function useConversationSuggestions(messages?: readonly AutopilotChatMessage[]) {
const { suggestions, firstRunExperience } = useChatState();

return {
suggestions: suggestions.items,
// Material's rule: `setSuggestions`' flag, else the first-run setting.
sendOnClick: suggestions.sendOnClick ?? firstRunExperience?.sendOnClick ?? false,
attached:
suggestions.items.length > 0 &&
messages !== undefined &&
messages.length > 0 &&
countUserTurns(messages) === suggestions.userTurns,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ import {
type AutopilotChatConfiguration,
type AutopilotChatDisabledFeatures,
AutopilotChatEvent,
AutopilotChatInterceptableEvent,
AutopilotChatInternalEvent,
type AutopilotChatMessage,
AutopilotChatMode,
type AutopilotChatOverrideLabels,
AutopilotChatRole,
type AutopilotChatSuggestion,
CHAT_COMPACT_MODE_INPUT_MAX_ROWS,
CHAT_COMPACT_MODE_INPUT_MIN_ROWS,
CHAT_COMPACT_MODE_MESSAGE_GROUP_GAP,
Expand Down Expand Up @@ -44,8 +48,29 @@ interface AutopilotChatStateContextType {
theming?: AutopilotChatConfiguration['theming'];
portalContainer?: HTMLElement;
readOnly: boolean;
/** The first-run experience shows: it has content and the conversation is empty. */
firstRunVisible: boolean;
/** The service's current suggestions (`setSuggestions`), cleared by the service on request. */
suggestions: ChatSuggestionsState;
}

/** Suggestions from `setSuggestions`, with the user turn they were set in. */
export interface ChatSuggestionsState {
items: AutopilotChatSuggestion[];
/** As passed to `setSuggestions`; `undefined` falls back to the first-run setting. */
sendOnClick?: boolean;
/**
* How many user messages the conversation had when the suggestions were set. They belong to
* that turn, replies included, and are left behind once another user message follows.
*/
userTurns?: number;
}

export const countUserTurns = (messages: readonly AutopilotChatMessage[]) =>
messages.reduce((count, message) => count + (message.role === AutopilotChatRole.User ? 1 : 0), 0);

const NO_SUGGESTIONS: ChatSuggestionsState = { items: [] };

const AutopilotChatStateContext = React.createContext<AutopilotChatStateContextType | null>(null);

interface AutopilotChatStateProviderProps {
Expand Down Expand Up @@ -163,10 +188,15 @@ export const AutopilotChatStateProvider: React.FC<AutopilotChatStateProviderProp
suggestions: [],
}
);
const [hasMessages, setHasMessages] = React.useState(false);
// Kept in step with the service from the same events as the transcript, so the first-run
// experience never shows for a render beside an existing conversation.
const [hasMessages, setHasMessages] = React.useState(
() => (chatService?.getConversation?.().length ?? 0) > 0
);
const [readOnly, setReadOnly] = React.useState(chatService?.getReadOnly() ?? false);
const [spacing, setSpacing] = React.useState(calculateSpacing(chatService?.getConfig()?.spacing));
const [theming, setTheming] = React.useState(chatService?.getConfig()?.theming);
const [suggestions, setSuggestions] = React.useState<ChatSuggestionsState>(NO_SUGGESTIONS);

React.useEffect(() => {
if (!chatService) {
Expand Down Expand Up @@ -240,7 +270,41 @@ export const AutopilotChatStateProvider: React.FC<AutopilotChatStateProviderProp
}
);

const unsubscribeSuggestions = chatInternalService.on(
AutopilotChatInternalEvent.SetSuggestions,
({
suggestions: items,
sendOnClick,
}: {
suggestions: AutopilotChatSuggestion[];
sendOnClick?: boolean;
}) => {
setSuggestions(
items.length === 0
? NO_SUGGESTIONS
: { items, sendOnClick, userTurns: countUserTurns(chatService.getConversation()) }
);
}
);

const unsubscribes = [
chatService.intercept(AutopilotChatInterceptableEvent.Request, () => {
setHasMessages(true);
}),
chatService.on(AutopilotChatEvent.Response, () => setHasMessages(true)),
chatService.on(AutopilotChatEvent.SetConversation, (conversation: AutopilotChatMessage[]) =>
setHasMessages(conversation.length > 0)
),
chatService.on(AutopilotChatEvent.NewChat, () => {
setHasMessages(false);
// A new chat starts without the last one's suggestions, in both renderers.
setSuggestions(NO_SUGGESTIONS);
}),
];

return () => {
unsubscribeSuggestions();
for (const unsubscribe of unsubscribes) unsubscribe();
unsubscribeHistoryToggle();
unsubscribeSettingsToggle();
unsubscribeModeChange();
Expand All @@ -254,6 +318,15 @@ export const AutopilotChatStateProvider: React.FC<AutopilotChatStateProviderProp
};
}, [chatService, chatInternalService]);

const firstRunVisible =
!hasMessages &&
!!firstRunExperience &&
!!(
firstRunExperience.title ||
firstRunExperience.description ||
firstRunExperience.suggestions?.length
);

const value = React.useMemo(
() => ({
historyOpen,
Expand All @@ -273,6 +346,8 @@ export const AutopilotChatStateProvider: React.FC<AutopilotChatStateProviderProp
theming,
portalContainer,
readOnly,
firstRunVisible,
suggestions,
}),
[
historyOpen,
Expand All @@ -292,6 +367,8 @@ export const AutopilotChatStateProvider: React.FC<AutopilotChatStateProviderProp
theming,
portalContainer,
readOnly,
firstRunVisible,
suggestions,
]
);

Expand Down
Loading
Loading