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
89 changes: 89 additions & 0 deletions packages/apollo-react/src/chat/headless/hooks/use-chat-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react';

import {
type AutopilotChatActionPayload,
AutopilotChatEvent,
AutopilotChatInterceptableEvent,
type AutopilotChatMessage,
AutopilotChatRole,
} from '../../service';
import {
getMessageCopyText,
groupChatMessages,
withoutFakeStream,
} from '../messages/message-groups';
import { useChatService } from '../providers/chat-service.provider';
import { useChatState } from '../providers/chat-state-provider';

/**
* The transcript a renderer shows, kept in step with the service, plus the service-side effects of
* the built-in message actions (feedback is written back onto the message, copy fills the
* clipboard). Mount it once per chat: it handles those action events.
*/
export function useChatMessages() {
const chatService = useChatService();
const { setHasMessages } = useChatState();
const [messages, setMessages] = React.useState<AutopilotChatMessage[]>(() =>
withoutFakeStream(chatService?.getConversation?.() ?? [])
);

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

// Patch an existing message or append a new one.
const upsert = (message: AutopilotChatMessage) => {
setMessages((previous) =>
previous.some((existing) => existing.id === message.id)
? previous.map((existing) => (existing.id === message.id ? message : existing))
: [...previous, message]
);
};

const sendFeedback = ({ message, action }: AutopilotChatActionPayload) => {
if (message.role !== AutopilotChatRole.Assistant) {
return;
}

chatService.sendResponse({
...message,
...(message.fakeStream ? { fakeStream: false } : {}),
...(message.stream ? { stream: false, done: true } : {}),
feedback: { isPositive: !!action.details?.isPositive },
} satisfies AutopilotChatMessage);
};

const copy = ({ group, message }: AutopilotChatActionPayload) => {
const text = getMessageCopyText(group, message);

if (navigator.clipboard && text) {
navigator.clipboard.writeText(text).catch(() => {});
}
};

const unsubscribes = [
// An interceptor, so the user's message shows without waiting for the host to confirm it.
chatService.intercept(AutopilotChatInterceptableEvent.Request, upsert),
chatService.on(AutopilotChatEvent.Response, upsert),
chatService.on(AutopilotChatEvent.NewChat, () => setMessages([])),
chatService.on(AutopilotChatEvent.SetConversation, (conversation: AutopilotChatMessage[]) =>
setMessages(withoutFakeStream(conversation))
),
chatService.on(AutopilotChatEvent.Feedback, sendFeedback),
chatService.on(AutopilotChatEvent.Copy, copy),
];

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

React.useEffect(() => {
setHasMessages(messages.length > 0);
}, [messages, setHasMessages]);

const groups = React.useMemo(() => groupChatMessages(messages), [messages]);

return { messages, groups };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
AutopilotChatEvent,
AutopilotChatInternalEvent,
AutopilotChatMode,
AutopilotChatRole,
AutopilotChatService,
} from '../../service';
import { AutopilotChatServiceProvider } from '../providers/chat-service.provider';
import { AutopilotLoadingProvider } from '../providers/loading-provider';
import { AutopilotStreamingProvider } from '../providers/streaming-provider';
import { useChatScrollAdapter } from './use-chat-scroll-adapter';

let instanceCounter = 0;

function Probe({
canScrollToStart,
scrollToEnd,
layoutSettled = true,
}: {
canScrollToStart: boolean;
scrollToEnd: () => void;
layoutSettled?: boolean;
}) {
useChatScrollAdapter({ commands: { scrollToEnd }, canScrollToStart, layoutSettled });
return null;
}

function setup(paginatedMessages = false) {
const service = AutopilotChatService.Instantiate({
instanceName: `scroll-adapter-${++instanceCounter}`,
config: { mode: AutopilotChatMode.Embedded, paginatedMessages },
});
const scrollToEnd = vi.fn();
const loadMore = vi.fn();
service.on(AutopilotChatEvent.ConversationLoadMore, loadMore);
const tree = (canScrollToStart: boolean, { layoutSettled = true, key = 'probe' } = {}) => (
<AutopilotChatServiceProvider chatServiceInstance={service}>
<AutopilotStreamingProvider>
<AutopilotLoadingProvider>
<Probe
key={key}
canScrollToStart={canScrollToStart}
scrollToEnd={scrollToEnd}
layoutSettled={layoutSettled}
/>
</AutopilotLoadingProvider>
</AutopilotStreamingProvider>
</AutopilotChatServiceProvider>
);
const view = render(tree(false));

return {
service,
scrollToEnd,
loadMore,
at: (canScrollToStart: boolean, options?: { layoutSettled?: boolean; key?: string }) =>
view.rerender(tree(canScrollToStart, options)),
};
}

describe('useChatScrollAdapter', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame'] });
});
afterEach(() => {
vi.useRealTimers();
});

it('returns to the latest message smoothly on a new turn, new chat or opened conversation', async () => {
const { service, scrollToEnd } = setup();

await act(async () => {
service.sendRequest({ content: 'hi' });
});
act(() => service.newChat());
act(() => service.openConversation('c1'));

expect(scrollToEnd).toHaveBeenCalledTimes(3);
expect(scrollToEnd).toHaveBeenNthCalledWith(1, { behavior: 'smooth' });
});

it('returns instantly while a reply streams', async () => {
const { service, scrollToEnd } = setup();
act(() =>
service.sendResponse({ id: 'r1', content: 'a', stream: true, widget: 'x', created_at: '' })
);

await act(async () => {
service.sendRequest({ content: 'hi' });
});

expect(scrollToEnd).toHaveBeenLastCalledWith({ behavior: 'instant' });
});

it('jumps instantly once a replaced conversation or a new mode has rendered', () => {
const { service, scrollToEnd } = setup();

act(() => service.setConversation([]));
act(() => service.setChatMode(AutopilotChatMode.FullScreen));
expect(scrollToEnd).not.toHaveBeenCalled();

act(() => vi.runAllTimers());
expect(scrollToEnd).toHaveBeenCalledTimes(1);
expect(scrollToEnd).toHaveBeenCalledWith({ behavior: 'instant' });
});

it('asks for older messages once per arrival at the top', () => {
const { service, loadMore, at } = setup(true);
const loading = vi.fn();
service.__internalService__.on(AutopilotChatInternalEvent.SetIsLoadingMoreMessages, loading);

at(true);
at(false);
at(false);
expect(loadMore).toHaveBeenCalledTimes(1);
expect(loading).toHaveBeenCalledWith(true);

// Still loading: leaving and coming back does not ask again.
at(true);
at(false);
expect(loadMore).toHaveBeenCalledTimes(1);

act(() => service.prependOlderMessages([], false));
act(() =>
service.__internalService__.publish(
AutopilotChatInternalEvent.SetIsLoadingMoreMessages,
false
)
);
at(true);
at(false);
expect(loadMore).toHaveBeenCalledTimes(2);
});

it('stops asking once the host says there is nothing older', () => {
const { service, loadMore, at } = setup(true);
act(() =>
service.__internalService__.publish(
AutopilotChatInternalEvent.ShouldShowLoadingMoreMessages,
false
)
);

at(true);
at(false);

expect(loadMore).not.toHaveBeenCalled();
});

it('does not ask when the conversation is not paginated', () => {
const { loadMore, at } = setup(false);

at(true);
at(false);

expect(loadMore).not.toHaveBeenCalled();
});

it('ignores edge changes until the layout has settled', () => {
const { loadMore, at } = setup(true);

// A first layout at 0px height opens "at the end", then the page fits once the viewport grows.
at(true, { layoutSettled: false });
at(false, { layoutSettled: false });
at(false, { layoutSettled: true });
expect(loadMore).not.toHaveBeenCalled();

at(true);
at(false);
expect(loadMore).toHaveBeenCalledTimes(1);
});

it('does not ask again after a remount once the host has nothing older', () => {
const { service, loadMore, at } = setup(true);
act(() => service.prependOlderMessages([], true));
act(() => vi.runAllTimers());

at(true, { key: 'remounted' });
at(false, { key: 'remounted' });

expect(loadMore).not.toHaveBeenCalled();
});

it('keeps the position when older messages are prepended', () => {
const { service, scrollToEnd } = setup(true);
service.setConversation([
{ id: 'b', content: 'b', created_at: '', role: AutopilotChatRole.User, widget: '' },
]);
act(() => vi.runAllTimers());
scrollToEnd.mockClear();

act(() =>
service.prependOlderMessages([
{ id: 'a', content: 'a', created_at: '', role: AutopilotChatRole.User, widget: '' },
])
);
act(() => vi.runAllTimers());

expect(scrollToEnd).not.toHaveBeenCalled();

// A later replacement still jumps to the end.
act(() => service.setConversation([]));
act(() => vi.runAllTimers());
expect(scrollToEnd).toHaveBeenCalledWith({ behavior: 'instant' });
});
});
Loading
Loading