diff --git a/packages/apollo-react/src/chat/headless/hooks/use-chat-messages.ts b/packages/apollo-react/src/chat/headless/hooks/use-chat-messages.ts new file mode 100644 index 0000000000..6f74820a08 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-chat-messages.ts @@ -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(() => + 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 }; +} diff --git a/packages/apollo-react/src/chat/headless/hooks/use-chat-scroll-adapter.test.tsx b/packages/apollo-react/src/chat/headless/hooks/use-chat-scroll-adapter.test.tsx new file mode 100644 index 0000000000..5a73961563 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-chat-scroll-adapter.test.tsx @@ -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' } = {}) => ( + + + + + + + + ); + 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' }); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/hooks/use-chat-scroll-adapter.ts b/packages/apollo-react/src/chat/headless/hooks/use-chat-scroll-adapter.ts new file mode 100644 index 0000000000..d1183c48ca --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-chat-scroll-adapter.ts @@ -0,0 +1,122 @@ +import React from 'react'; + +import { + AutopilotChatEvent, + AutopilotChatInterceptableEvent, + AutopilotChatInternalEvent, +} from '../../service'; +import { useChatService } from '../providers/chat-service.provider'; +import { useLoading } from '../providers/loading-provider'; +import { useStreaming } from '../providers/streaming-provider'; +import { publishChatEvent } from '../utils/publish-chat-event'; + +export type ChatScrollBehavior = 'instant' | 'smooth'; + +/** The commands of the renderer's scroll engine the adapter drives. */ +export interface ChatScrollCommands { + scrollToEnd: (options?: { behavior?: ScrollBehavior }) => unknown; +} + +export interface ChatScrollAdapterOptions { + commands: ChatScrollCommands; + /** Whether the viewport can still scroll toward the start, i.e. is not at the top. */ + canScrollToStart: boolean; + /** + * Whether the viewport has its real size. Until then edge changes are layout settling, not the + * reader arriving at the top, and do not ask for older messages. + */ + layoutSettled: boolean; +} + +/** + * Maps chat service events onto a renderer's scroll engine, knowing nothing about how it scrolls: + * a new turn, a new chat or an opened conversation return to the latest message (instantly while + * streaming); a replaced conversation or a mode change jumps there instantly once rendered, but + * prepended older messages do not; and reaching the top asks the host for older messages, once per + * arrival, when the conversation is paginated, nothing is loading and the host has more. + */ +export function useChatScrollAdapter({ + commands, + canScrollToStart, + layoutSettled, +}: ChatScrollAdapterOptions) { + const chatService = useChatService(); + const { streaming } = useStreaming(); + const { isLoadingMoreMessages, hasMoreMessages } = useLoading(); + const commandsRef = React.useRef(commands); + commandsRef.current = commands; + const streamingRef = React.useRef(streaming); + streamingRef.current = streaming; + + React.useEffect(() => { + if (!chatService) { + return; + } + + let frame = 0; + // `prependOlderMessages` announces itself, then replaces the conversation; that replacement + // keeps the reader's position instead of jumping to the end. + let prepending = false; + const toLatest = () => + commandsRef.current.scrollToEnd({ behavior: streamingRef.current ? 'instant' : 'smooth' }); + // After the new transcript has rendered, so the end is the new end. + const toLatestInstantlyAfterRender = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => commandsRef.current.scrollToEnd({ behavior: 'instant' })); + }; + const internal = chatService.__internalService__; + + const unsubscribes = [ + chatService.intercept(AutopilotChatInterceptableEvent.Request, () => { + toLatest(); + }), + chatService.on(AutopilotChatEvent.NewChat, toLatest), + chatService.on(AutopilotChatEvent.OpenConversation, toLatest), + internal.on(AutopilotChatInternalEvent.PrependOlderMessages, () => { + prepending = true; + }), + chatService.on(AutopilotChatEvent.SetConversation, () => { + if (prepending) { + prepending = false; + return; + } + toLatestInstantlyAfterRender(); + }), + chatService.on(AutopilotChatEvent.ModeChange, toLatestInstantlyAfterRender), + ]; + + return () => { + cancelAnimationFrame(frame); + for (const unsubscribe of unsubscribes) unsubscribe(); + }; + }, [chatService]); + + // Fires on arriving at the top, not while staying there, so each visit asks once. Starts from + // wherever the settled layout is. + const wasAwayFromTop = React.useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: only edge changes ask; loading and has-more state just gate them + React.useEffect(() => { + if (!layoutSettled) { + return; + } + + const arrivedAtTop = wasAwayFromTop.current === true && !canScrollToStart; + wasAwayFromTop.current = canScrollToStart; + + if ( + !arrivedAtTop || + !chatService?.getConfig()?.paginatedMessages || + isLoadingMoreMessages || + !hasMoreMessages + ) { + return; + } + + publishChatEvent(chatService, AutopilotChatEvent.ConversationLoadMore); + chatService.__internalService__.publish( + AutopilotChatInternalEvent.SetIsLoadingMoreMessages, + true + ); + }, [canScrollToStart, layoutSettled, chatService]); +} diff --git a/packages/apollo-react/src/chat/headless/hooks/use-injected-message-renderer.ts b/packages/apollo-react/src/chat/headless/hooks/use-injected-message-renderer.ts new file mode 100644 index 0000000000..e1e23411f1 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-injected-message-renderer.ts @@ -0,0 +1,28 @@ +import React from 'react'; + +import type { AutopilotChatMessage } from '../../service'; +import { useChatService } from '../providers/chat-service.provider'; + +/** + * Hands `ref`'s element to the renderer injected for `message.widget` and runs its cleanup before + * the next render and on unmount. An effect rather than a ref callback: React calls a new ref + * callback on every render, which would render again without cleaning up first. + */ +export function useInjectedMessageRenderer( + ref: React.RefObject, + message: AutopilotChatMessage +) { + const chatService = useChatService(); + + React.useLayoutEffect(() => { + if (!ref.current) { + return; + } + + const cleanup = chatService.renderMessage(ref.current, message); + + return () => { + cleanup?.(); + }; + }, [chatService, message, ref]); +} diff --git a/packages/apollo-react/src/chat/headless/hooks/use-loading-messages.ts b/packages/apollo-react/src/chat/headless/hooks/use-loading-messages.ts new file mode 100644 index 0000000000..f7c0ff70f1 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-loading-messages.ts @@ -0,0 +1,63 @@ +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'; + +/** 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; + +/** + * 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`. + */ +export function useLoadingMessages() { + const { _ } = useLingui(); + const chatService = useChatService(); + const [messages, setMessages] = React.useState( + () => 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 translate = React.useCallback( + (message: string) => + message === DEFAULT_LOADING_MESSAGE_KEY + ? _( + msg({ + id: 'autopilot-chat.message.generating-response', + message: `Generating response...`, + }) + ) + : message, + [_] + ); + + return { messages, duration, translate }; +} diff --git a/packages/apollo-react/src/chat/headless/hooks/use-message-actions.test.tsx b/packages/apollo-react/src/chat/headless/hooks/use-message-actions.test.tsx new file mode 100644 index 0000000000..08784fc04b --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-message-actions.test.tsx @@ -0,0 +1,66 @@ +import { act, render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { type AutopilotChatMessage, AutopilotChatRole, AutopilotChatService } from '../../service'; +import { AutopilotChatServiceProvider } from '../providers/chat-service.provider'; +import { useIsLastAssistantMessage } from './use-message-actions'; + +let instanceCounter = 0; + +const assistant = ( + id: string, + extra: Partial = {} +): AutopilotChatMessage => ({ + id, + content: id, + created_at: '', + role: AutopilotChatRole.Assistant, + widget: 'apollo-markdown-renderer', + ...extra, +}); + +function setup(conversation: AutopilotChatMessage[]) { + const service = AutopilotChatService.Instantiate({ + instanceName: `latest-reply-${++instanceCounter}`, + }); + service.setConversation(conversation); + const latest: Record = {}; + const Probe = ({ message }: { message: AutopilotChatMessage }) => { + latest[message.id] = useIsLastAssistantMessage(message); + return null; + }; + render( + + {conversation.map((message) => ( + + ))} + + ); + return { service, latest }; +} + +describe('useIsLastAssistantMessage', () => { + it('marks only the latest reply in a transcript without groupIds', () => { + const { latest } = setup([assistant('a1'), assistant('a2'), assistant('a3')]); + + expect(latest).toEqual({ a1: false, a2: false, a3: true }); + }); + + it('keeps the latest reply when an older one is re-sent with feedback', () => { + const { service, latest } = setup([assistant('a1'), assistant('a2')]); + + act(() => service.sendResponse({ ...assistant('a1'), feedback: { isPositive: true } })); + + expect(latest).toEqual({ a1: false, a2: true }); + }); + + it('counts the messages of the latest reply group', () => { + const { latest } = setup([ + assistant('a1', { groupId: 'g1' }), + assistant('b1', { groupId: 'g2' }), + assistant('b2', { groupId: 'g2' }), + ]); + + expect(latest).toEqual({ a1: false, b1: true, b2: true }); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/hooks/use-message-actions.ts b/packages/apollo-react/src/chat/headless/hooks/use-message-actions.ts new file mode 100644 index 0000000000..315c86e698 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-message-actions.ts @@ -0,0 +1,167 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import React from 'react'; + +import { + type AutopilotChatActionPayload, + AutopilotChatEvent, + type AutopilotChatMessage, + type AutopilotChatMessageAction, + AutopilotChatPreHookAction, + AutopilotChatRole, +} from '../../service'; +import { useChatService } from '../providers/chat-service.provider'; +import { useChatState } from '../providers/chat-state-provider'; +import { publishChatEvent } from '../utils/publish-chat-event'; + +export const MESSAGE_ACTION_COPY = 'autopilot-chat-copy'; +export const MESSAGE_ACTION_GOOD = 'autopilot-chat-good'; +export const MESSAGE_ACTION_BAD = 'autopilot-chat-bad'; + +export interface DefaultMessageActionsOptions { + /** + * Keep both thumbs actionable after a rating so it can be changed, each change publishing + * `Feedback` again. Off, a rating is final: only the chosen thumb remains, disabled (Material). + */ + allowRatingChange?: boolean; +} + +/** + * The built-in actions of a message: copy on every message, and thumbs up and down on assistant + * messages. + */ +export function useDefaultMessageActions( + message: AutopilotChatMessage, + { allowRatingChange = false }: DefaultMessageActionsOptions = {} +) { + const { _ } = useLingui(); + const { disabledFeatures, readOnly } = useChatState(); + + return React.useMemo(() => { + const actions: AutopilotChatMessageAction[] = []; + + if (!disabledFeatures.copy) { + actions.push({ + name: MESSAGE_ACTION_COPY, + label: _(msg({ id: 'autopilot-chat.message.actions.copy', message: `Copy` })), + icon: 'content_copy', + eventName: AutopilotChatEvent.Copy, + }); + } + + if (message.role !== AutopilotChatRole.Assistant || readOnly || disabledFeatures.feedback) { + return actions; + } + + const good = _(msg({ id: 'autopilot-chat.message.actions.good', message: `Good response` })); + const bad = _(msg({ id: 'autopilot-chat.message.actions.bad', message: `Bad response` })); + + if (message.feedback && !allowRatingChange) { + const { isPositive } = message.feedback; + + return [ + ...actions, + { + name: isPositive ? MESSAGE_ACTION_GOOD : MESSAGE_ACTION_BAD, + label: isPositive ? good : bad, + icon: isPositive ? 'thumb_up' : 'thumb_down', + disabled: true, + }, + ]; + } + + return [ + ...actions, + { + name: MESSAGE_ACTION_GOOD, + label: good, + icon: 'thumb_up', + eventName: AutopilotChatEvent.Feedback, + details: { isPositive: true, preHookAction: AutopilotChatPreHookAction.Feedback }, + }, + { + name: MESSAGE_ACTION_BAD, + label: bad, + icon: 'thumb_down', + eventName: AutopilotChatEvent.Feedback, + details: { isPositive: false, preHookAction: AutopilotChatPreHookAction.Feedback }, + }, + ]; + }, [message, readOnly, disabledFeatures.feedback, disabledFeatures.copy, allowRatingChange, _]); +} + +/** + * Whether `message` belongs to the latest assistant reply, whose actions stay visible: it is that + * message, or shares its `groupId`. Messages without a `groupId` only match themselves. For + * renderers that track it per message; a list can derive it from what it renders instead. + */ +export function useIsLastAssistantMessage(message: AutopilotChatMessage) { + const chatService = useChatService(); + const [isLast, setIsLast] = React.useState(false); + + React.useEffect(() => { + if (!chatService) { + return; + } + + const sameReply = (candidate: AutopilotChatMessage | undefined) => + !!candidate && + (candidate.id === message.id || (!!message.groupId && candidate.groupId === message.groupId)); + // From the transcript, not the event payload: feedback re-sends an older reply as a Response, + // which is not the latest one. + const update = () => { + const assistantMessages = chatService + .getConversation?.() + ?.filter((candidate) => candidate.role === AutopilotChatRole.Assistant); + setIsLast(sameReply(assistantMessages?.[assistantMessages.length - 1])); + }; + + update(); + + return chatService.on(AutopilotChatEvent.Response, update); + }, [chatService, message]); + + return isLast; +} + +/** + * Runs a message action: its pre-hook first, when it has one, then its event with the message and + * its group as payload. + */ +export function useMessageActionHandler(message: AutopilotChatMessage) { + const chatService = useChatService(); + + return React.useCallback( + async (action: AutopilotChatMessageAction) => { + if (!action.eventName || !chatService) { + return; + } + + const group = chatService.getMessagesInGroup(message.groupId ?? ''); + const currentMessage = chatService + .getConversation() + .find((candidate) => candidate.id === message.id) as AutopilotChatMessage; + + if (action.details?.preHookAction) { + const proceed = await chatService.getPreHook( + action.details.preHookAction as AutopilotChatPreHookAction + )({ + isPositive: action.details.isPositive, + group, + message: currentMessage, + }); + + if (!proceed) { + return; + } + } + + publishChatEvent(chatService, action.eventName, { + group, + message: currentMessage, + action, + } satisfies AutopilotChatActionPayload); + }, + [message, chatService] + ); +} diff --git a/packages/apollo-react/src/chat/headless/hooks/use-message-inset.ts b/packages/apollo-react/src/chat/headless/hooks/use-message-inset.ts new file mode 100644 index 0000000000..50ad59221e --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-message-inset.ts @@ -0,0 +1,55 @@ +import React from 'react'; + +import { + AutopilotChatEvent, + AutopilotChatInternalEvent, + AutopilotChatMode, + CHAT_MESSAGE_MAX_PADDING, + CHAT_WIDTH_KEY, + CHAT_WIDTH_SIDE_BY_SIDE_MIN, + StorageService, +} from '../../service'; +import { useChatService } from '../providers/chat-service.provider'; +import { calculateDynamicPadding } from '../utils/dynamic-padding'; + +const storedWidth = () => + parseInt( + StorageService.Instance.get(CHAT_WIDTH_KEY) ?? CHAT_WIDTH_SIDE_BY_SIDE_MIN.toString(), + 10 + ); + +/** + * How far a user message stays from the leading edge, in pixels: wider as the chat widens, and the + * maximum in FullScreen. + */ +export function useMessageInset() { + const chatService = useChatService(); + // Starts from the stored width in every mode, as Material always has; FullScreen reaches the + // maximum through ModeChange or the layout's ChatResize. + const [inset, setInset] = React.useState(() => calculateDynamicPadding(storedWidth())); + + React.useEffect(() => { + if (!chatService) { + return; + } + + const unsubscribes = [ + chatService.__internalService__.on(AutopilotChatInternalEvent.ChatResize, (width: number) => + setInset(calculateDynamicPadding(width)) + ), + chatService.on(AutopilotChatEvent.ModeChange, (mode: AutopilotChatMode) => + setInset( + mode === AutopilotChatMode.FullScreen + ? CHAT_MESSAGE_MAX_PADDING + : calculateDynamicPadding(storedWidth()) + ) + ), + ]; + + return () => { + for (const unsubscribe of unsubscribes) unsubscribe(); + }; + }, [chatService]); + + return inset; +} diff --git a/packages/apollo-react/src/chat/headless/hooks/use-settled-replies.test.tsx b/packages/apollo-react/src/chat/headless/hooks/use-settled-replies.test.tsx new file mode 100644 index 0000000000..776adb286a --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-settled-replies.test.tsx @@ -0,0 +1,120 @@ +import { act, render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { type AutopilotChatMessage, AutopilotChatRole, AutopilotChatService } from '../../service'; +import { AutopilotChatServiceProvider } from '../providers/chat-service.provider'; +import { useSettledReplies } from './use-settled-replies'; + +let instanceCounter = 0; + +function setup(restored: AutopilotChatMessage[] = []) { + const service = AutopilotChatService.Instantiate({ + instanceName: `settled-${++instanceCounter}`, + }); + service.setConversation(restored); + const settled = vi.fn(); + const Probe = () => { + useSettledReplies(settled); + return null; + }; + render( + + + + ); + return { service, settled }; +} + +const reply = (extra: Partial = {}) => ({ + widget: 'apollo-markdown-renderer', + created_at: '', + ...extra, +}); + +describe('useSettledReplies', () => { + it('reports a complete reply once, even when it is re-sent with feedback', () => { + const { service, settled } = setup(); + + act(() => service.sendResponse(reply({ id: 'r1', content: 'done' }))); + act(() => + service.sendResponse(reply({ id: 'r1', content: 'done', feedback: { isPositive: true } })) + ); + + expect(settled).toHaveBeenCalledTimes(1); + }); + + it('reports a streamed reply when its last chunk arrives', () => { + const { service, settled } = setup(); + + act(() => service.sendResponse(reply({ id: 's1', content: 'a', stream: true }))); + act(() => service.sendResponse(reply({ id: 's1', content: 'b', stream: true }))); + expect(settled).not.toHaveBeenCalled(); + + act(() => service.sendResponse(reply({ id: 's1', content: '', stream: true, done: true }))); + + expect(settled).toHaveBeenCalledTimes(1); + expect(settled.mock.calls[0]?.[0]).toMatchObject({ + id: 's1', + role: AutopilotChatRole.Assistant, + }); + }); + + it('reports a stopped stream once', () => { + const { service, settled } = setup(); + + act(() => service.sendResponse(reply({ id: 's2', content: 'a', stream: true }))); + act(() => service.stopResponse()); + + expect(settled).toHaveBeenCalledTimes(1); + }); + + it('ignores restored conversations and user turns', () => { + const { service, settled } = setup(); + + act(() => + service.setConversation([ + { + id: 'old', + content: 'old', + created_at: '', + role: AutopilotChatRole.Assistant, + widget: '', + }, + ]) + ); + act(() => service.sendRequest({ content: 'hi' })); + + expect(settled).not.toHaveBeenCalled(); + }); + + it('does not report a restored reply when it is re-sent with feedback', () => { + const restored = { + id: 'old', + content: 'old', + created_at: '', + role: AutopilotChatRole.Assistant, + widget: 'apollo-markdown-renderer', + }; + const { service, settled } = setup([restored]); + + act(() => service.sendResponse({ ...restored, feedback: { isPositive: true } })); + + expect(settled).not.toHaveBeenCalled(); + }); + + it('does not report a reply restored after mount when it is re-sent', () => { + const { service, settled } = setup(); + const restored = { + id: 'later', + content: 'later', + created_at: '', + role: AutopilotChatRole.Assistant, + widget: 'apollo-markdown-renderer', + }; + + act(() => service.setConversation([restored])); + act(() => service.sendResponse({ ...restored, feedback: { isPositive: false } })); + + expect(settled).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/hooks/use-settled-replies.ts b/packages/apollo-react/src/chat/headless/hooks/use-settled-replies.ts new file mode 100644 index 0000000000..18e55b3d0d --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-settled-replies.ts @@ -0,0 +1,64 @@ +import React from 'react'; + +import { AutopilotChatEvent, type AutopilotChatMessage, AutopilotChatRole } from '../../service'; +import { useChatService } from '../providers/chat-service.provider'; + +/** + * Calls `onSettled` once for each assistant reply that finishes arriving during this session: on + * a complete response, on the last chunk of a stream, or when a stream is stopped. Messages that + * were already in the transcript (restored, or present before a remount) do not count. + */ +export function useSettledReplies(onSettled: (message: AutopilotChatMessage) => void) { + const chatService = useChatService(); + const onSettledRef = React.useRef(onSettled); + onSettledRef.current = onSettled; + + React.useEffect(() => { + if (!chatService) { + return; + } + + // Replies already in the transcript (restored, or rendered before a remount) are not new: + // re-sending one, as feedback does, must not announce it. + const settled = new Set(); + const streaming = new Map(); + // A stream still running is left out, so it is announced when it ends. + const seed = (conversation: AutopilotChatMessage[] = []) => { + for (const message of conversation) { + if (!(message.stream && !message.done)) settled.add(message.id); + } + }; + seed(chatService.getConversation()); + const settle = (message: AutopilotChatMessage) => { + streaming.delete(message.id); + if (settled.has(message.id)) { + return; + } + settled.add(message.id); + onSettledRef.current(message); + }; + const track = (message: AutopilotChatMessage) => { + if (message.role !== AutopilotChatRole.Assistant) { + return; + } + if (message.stream && !message.done) { + streaming.set(message.id, message); + } else { + settle(message); + } + }; + + const unsubscribes = [ + chatService.on(AutopilotChatEvent.SetConversation, seed), + chatService.on(AutopilotChatEvent.Response, track), + chatService.on(AutopilotChatEvent.SendChunk, track), + chatService.on(AutopilotChatEvent.StopResponse, () => { + for (const message of [...streaming.values()]) settle(message); + }), + ]; + + return () => { + for (const unsubscribe of unsubscribes) unsubscribe(); + }; + }, [chatService]); +} diff --git a/packages/apollo-react/src/chat/headless/messages/message-groups.test.ts b/packages/apollo-react/src/chat/headless/messages/message-groups.test.ts new file mode 100644 index 0000000000..cd69d1625c --- /dev/null +++ b/packages/apollo-react/src/chat/headless/messages/message-groups.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { type AutopilotChatMessage, AutopilotChatRole } from '../../service'; +import { getMessageCopyText, groupChatMessages, withoutFakeStream } from './message-groups'; + +const message = (id: string, extra: Partial = {}): AutopilotChatMessage => ({ + id, + content: id, + created_at: '', + role: AutopilotChatRole.Assistant, + widget: 'apollo-markdown-renderer', + ...extra, +}); + +describe('groupChatMessages', () => { + it('groups consecutive messages that share a groupId and keeps the rest alone', () => { + const groups = groupChatMessages([ + message('u1', { role: AutopilotChatRole.User, groupId: 'g1' }), + message('a1', { groupId: 'g1' }), + message('a2', { groupId: 'g1' }), + message('a3'), + message('a4'), + message('a5', { groupId: 'g1' }), + ]); + + expect(groups.map((group) => group.map(({ id }) => id))).toEqual([ + ['u1', 'a1', 'a2'], + ['a3'], + ['a4'], + ['a5'], + ]); + }); + + it('does not group consecutive messages of the same role without a groupId', () => { + expect(groupChatMessages([message('a1'), message('a2')])).toHaveLength(2); + }); +}); + +describe('getMessageCopyText', () => { + it('joins the group, prefers toCopy and strips resource tokens', () => { + const token = '[[resource-token:{"id":"v","type":"variable","displayName":"email"}]]'; + const group = [message('a1', { content: `Update ${token}` }), message('a2', { toCopy: 'two' })]; + + expect(getMessageCopyText(group, message('a2'))).toBe('Update email\ntwo'); + }); + + it('copies the message itself when it has no group', () => { + expect(getMessageCopyText([], message('a1', { toCopy: 'only this' }))).toBe('only this'); + }); +}); + +describe('withoutFakeStream', () => { + it('drops fakeStream so restored messages render complete', () => { + expect(withoutFakeStream([message('a1', { fakeStream: true })])[0]).not.toHaveProperty( + 'fakeStream' + ); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/messages/message-groups.ts b/packages/apollo-react/src/chat/headless/messages/message-groups.ts new file mode 100644 index 0000000000..1c9d46a941 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/messages/message-groups.ts @@ -0,0 +1,44 @@ +import type { AutopilotChatMessage } from '../../service'; +import { stripResourceTokens } from '../markdown/resource-token-parser'; + +/** + * Splits a transcript into groups: consecutive messages sharing a `groupId` form one group, and a + * message without one is a group of its own. Renderers show actions and sources only on the last + * message of a group. + */ +export function groupChatMessages(messages: readonly AutopilotChatMessage[]) { + return messages.reduce((groups, message, index) => { + const previous = messages[index - 1]; + + if (message.groupId && previous?.groupId === message.groupId) { + groups[groups.length - 1]?.push(message); + } else { + groups.push([message]); + } + + return groups; + }, []); +} + +/** + * Drops `fakeStream` so a restored transcript renders complete instead of replaying its streams. + */ +export function withoutFakeStream(messages: readonly AutopilotChatMessage[]) { + return messages.map(({ fakeStream: _fakeStream, ...rest }) => rest); +} + +/** + * What the copy action puts on the clipboard: the whole group, or the message when it has none, + * preferring `toCopy`, with resource tokens reduced to their labels. + */ +export function getMessageCopyText( + group: readonly AutopilotChatMessage[], + message: AutopilotChatMessage +) { + const text = + group.length === 0 + ? (message.toCopy ?? message.content) + : group.map((member) => member.toCopy ?? member.content).join('\n'); + + return stripResourceTokens(text); +} diff --git a/packages/apollo-react/src/chat/headless/providers/chat-scroll-provider.test.tsx b/packages/apollo-react/src/chat/headless/providers/chat-scroll-provider.test.tsx new file mode 100644 index 0000000000..a29eed1b9b --- /dev/null +++ b/packages/apollo-react/src/chat/headless/providers/chat-scroll-provider.test.tsx @@ -0,0 +1,63 @@ +import { act, render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { AutopilotChatMode, AutopilotChatService } from '../../service'; +import { AutopilotChatScrollProvider, useChatScroll } from './chat-scroll-provider'; +import { AutopilotChatServiceProvider } from './chat-service.provider'; +import { AutopilotStreamingProvider } from './streaming-provider'; + +let instanceCounter = 0; +let autoScrollValue: boolean | undefined; + +function Probe() { + autoScrollValue = useChatScroll().autoScroll; + return null; +} + +// No scroll container is registered: the state must hold before a renderer mounts one. +function setup() { + const service = AutopilotChatService.Instantiate({ + instanceName: `scroll-provider-${++instanceCounter}`, + config: { mode: AutopilotChatMode.Embedded }, + }); + render( + + + + + + + + ); + + return service; +} + +describe('AutopilotChatScrollProvider', () => { + it('follows the service auto-scroll toggle before a scroll container exists', () => { + const service = setup(); + expect(autoScrollValue).toBe(true); + + act(() => service.toggleAutoScroll(false)); + + expect(autoScrollValue).toBe(false); + }); + + it('switches following back on for a new turn, a new chat or an opened conversation', async () => { + const service = setup(); + + act(() => service.toggleAutoScroll(false)); + await act(async () => { + service.sendRequest({ content: 'hi' }); + }); + expect(autoScrollValue).toBe(true); + + act(() => service.toggleAutoScroll(false)); + act(() => service.newChat()); + expect(autoScrollValue).toBe(true); + + act(() => service.toggleAutoScroll(false)); + act(() => service.openConversation('c1')); + expect(autoScrollValue).toBe(true); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/providers/chat-scroll-provider.tsx b/packages/apollo-react/src/chat/headless/providers/chat-scroll-provider.tsx index 346eb5c57c..7abc074429 100644 --- a/packages/apollo-react/src/chat/headless/providers/chat-scroll-provider.tsx +++ b/packages/apollo-react/src/chat/headless/providers/chat-scroll-provider.tsx @@ -247,33 +247,13 @@ export const AutopilotChatScrollProvider: React.FC { - const content = contentRef.current; - - if (!content || !overflowContainer || !chatService) { + if (!chatService) { return; } - // Initial checks - checkOverflow(); - checkContainerWidth(); - - const resizeObserver = new ResizeObserver(handleResize); - resizeObserver.observe(content); - - // Also observe the overflow container for size changes - const containerResizeObserver = new ResizeObserver(() => { - checkOverflow(); - checkContainerWidth(); - }); - containerResizeObserver.observe(overflowContainer); - - overflowContainer.addEventListener('mousedown', handleMouseDown); - overflowContainer.addEventListener('mouseup', handleMouseUp); - overflowContainer.addEventListener('keydown', handleKeyDown); - overflowContainer.addEventListener('wheel', handleWheel, { passive: true }); - overflowContainer.addEventListener('scroll', handleScroll, { passive: true }); - const unsubscribeRequestIntercept = chatService.intercept( AutopilotChatInterceptableEvent.Request, () => { @@ -320,6 +300,45 @@ export const AutopilotChatScrollProvider: React.FC { + unsubscribeRequestIntercept(); + unsubscribeNewChat(); + unsubscribeOpenConversation(); + unsubscribeSetConversation(); + unsubscribeModeChange(); + unsubscribeToggleAutoScroll(); + unsubscribeSetIsLoadingMoreMessages(); + unsubscribeShouldShowLoadingMoreMessages(); + }; + }, [chatService]); + + React.useEffect(() => { + const content = contentRef.current; + + if (!content || !overflowContainer || !chatService) { + return; + } + + // Initial checks + checkOverflow(); + checkContainerWidth(); + + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(content); + + // Also observe the overflow container for size changes + const containerResizeObserver = new ResizeObserver(() => { + checkOverflow(); + checkContainerWidth(); + }); + containerResizeObserver.observe(overflowContainer); + + overflowContainer.addEventListener('mousedown', handleMouseDown); + overflowContainer.addEventListener('mouseup', handleMouseUp); + overflowContainer.addEventListener('keydown', handleKeyDown); + overflowContainer.addEventListener('wheel', handleWheel, { passive: true }); + overflowContainer.addEventListener('scroll', handleScroll, { passive: true }); + const unsubscribePrependOlderMessages = chatService.__internalService__.on( AutopilotChatInternalEvent.PrependOlderMessages, () => { @@ -340,14 +359,6 @@ export const AutopilotChatScrollProvider: React.FC void; showLoading: boolean; skeletonLoader: boolean; @@ -20,6 +22,7 @@ interface AutopilotLoadingContextType { export const AutopilotLoadingContext = React.createContext({ waitingResponse: false, isLoadingMoreMessages: false, + hasMoreMessages: true, setWaitingResponse: () => {}, showLoading: false, skeletonLoader: false, @@ -28,6 +31,7 @@ export const AutopilotLoadingContext = React.createContext(false); const [isLoadingMoreMessages, setIsLoadingMoreMessages] = React.useState(false); + const [hasMoreMessages, setHasMoreMessages] = React.useState(true); const [showLoading, setShowLoading] = React.useState(false); const [skeletonLoader, setSkeletonLoader] = React.useState(false); const disableDefaultShowLoadingBehaviorRef = React.useRef(false); @@ -49,6 +53,7 @@ export function AutopilotLoadingProvider({ children }: { children: React.ReactNo const unsubscribeShouldShowLoadingMoreMessages = chatService.__internalService__.on( AutopilotChatInternalEvent.ShouldShowLoadingMoreMessages, (value: boolean) => { + setHasMoreMessages(value); if (!value) { setIsLoadingMoreMessages(false); } @@ -148,6 +153,7 @@ export function AutopilotLoadingProvider({ children }: { children: React.ReactNo waitingResponse, setWaitingResponse, isLoadingMoreMessages, + hasMoreMessages, showLoading, skeletonLoader, }} diff --git a/packages/apollo-react/src/chat/headless/utils/file-type.ts b/packages/apollo-react/src/chat/headless/utils/file-type.ts new file mode 100644 index 0000000000..3a5b42eacc --- /dev/null +++ b/packages/apollo-react/src/chat/headless/utils/file-type.ts @@ -0,0 +1,16 @@ +import { AutopilotChatFileType } from '../../service'; + +/** The friendly type the chat shows for an attached file, from its extension. */ +export function getAttachmentFileType(name: string): AutopilotChatFileType { + // Case-sensitive, as the Material renderer has always matched extensions. + switch (name.split('.').pop()) { + case 'doc': + case 'docx': + return AutopilotChatFileType.Word; + case 'ppt': + case 'pptx': + return AutopilotChatFileType.PowerPoint; + default: + return AutopilotChatFileType.File; + } +} diff --git a/packages/apollo-react/src/chat/headless/utils/publish-chat-event.ts b/packages/apollo-react/src/chat/headless/utils/publish-chat-event.ts index eefc6c9c27..70e0158a7e 100644 --- a/packages/apollo-react/src/chat/headless/utils/publish-chat-event.ts +++ b/packages/apollo-react/src/chat/headless/utils/publish-chat-event.ts @@ -4,7 +4,7 @@ import type { EventBus } from '../../service/EventBus'; /** * Publishes a public chat event on behalf of the UI (custom header actions, attachments, load * more). The service has no public `publish`; the headless layer reaches its private bus only - * through here. Material's message actions and history still access it directly. + * through here. Only Material's `chat-history.tsx` still reaches it directly. */ export function publishChatEvent( chatService: AutopilotChatService, diff --git a/packages/apollo-react/src/chat/locales/en.json b/packages/apollo-react/src/chat/locales/en.json index 489091619e..2cd98b61ae 100644 --- a/packages/apollo-react/src/chat/locales/en.json +++ b/packages/apollo-react/src/chat/locales/en.json @@ -81,5 +81,10 @@ "autopilot-chat.resource-picker.search-error": "Search failed", "autopilot-chat.resource-picker.back": "Go back", "autopilot-chat.input.resource-chip.remove": "Remove", - "autopilot-chat.input.resource-chip.remove-name": "Remove {label}" + "autopilot-chat.input.resource-chip.remove-name": "Remove {label}", + "autopilot-chat.message.list-label": "Conversation", + "autopilot-chat.message.response-received": "New response", + "autopilot-chat.message.actions.copied": "Copied", + "autopilot-chat.message.actions.feedback": "Rate this response", + "autopilot-chat.message.loading-older": "Loading older messages" } diff --git a/packages/apollo-react/src/chat/root/ApChat.registration.test.ts b/packages/apollo-react/src/chat/root/ApChat.registration.test.ts index b60a0ad24d..ba6687edc3 100644 --- a/packages/apollo-react/src/chat/root/ApChat.registration.test.ts +++ b/packages/apollo-react/src/chat/root/ApChat.registration.test.ts @@ -12,5 +12,6 @@ describe('ApChat module', () => { expect(registry.resolveChatRenderer(undefined, 'wind')).toBe(WindChatRenderer); expect(registry.resolveChatRenderer(undefined, 'material')).toBeDefined(); - }); + // A cold import of both renderers' module graphs exceeds the default 5s on a busy runner. + }, 30_000); }); diff --git a/packages/apollo-react/src/chat/stories/chat-playground.stories.tsx b/packages/apollo-react/src/chat/stories/chat-playground.stories.tsx index e526130397..57220ab57d 100644 --- a/packages/apollo-react/src/chat/stories/chat-playground.stories.tsx +++ b/packages/apollo-react/src/chat/stories/chat-playground.stories.tsx @@ -44,7 +44,7 @@ const featureArgTypes = Object.fromEntries( const ARG_BEHAVIOUR = ` Controls apply to the running chat through the service setters, without a new chat or a remount: -- **In place**: \`mode\` (setChatMode), \`readOnly\` (setReadOnly), Flags (setDisabledFeatures), \`title\`, \`inputPlaceholder\` and \`footerDisclaimer\` (setOverrideLabels), \`compactMode\` (patchConfig spacing), \`firstRunExperience\` (setFirstRunExperience, visible while the transcript is empty), \`customHeaderActions\` (setCustomHeaderActions), \`preHooks\` (setPreHook), \`locale\` (prop), \`autoReply\` and \`streamDelayMs\` (read on the next turn). The theme follows the Storybook theme toolbar and is passed to each chat as its \`theme\` prop. Switching between Embedded and FullScreen rebuilds the chat's DOM, because ChatRoot portals only in Embedded, but the service and transcript stay. +- **In place**: \`mode\` (setChatMode), \`readOnly\` (setReadOnly), Flags (setDisabledFeatures), \`title\`, \`inputPlaceholder\` and \`footerDisclaimer\` (setOverrideLabels), \`compactMode\` (patchConfig spacing), \`firstRunExperience\` (setFirstRunExperience, visible while the transcript is empty), \`customHeaderActions\` (setCustomHeaderActions), \`preHooks\` (setPreHook), \`autoScroll\` (toggleAutoScroll), \`locale\` (prop), \`autoReply\` and \`streamDelayMs\` (read on the next turn). The theme follows the Storybook theme toolbar and is passed to each chat as its \`theme\` prop. Switching between Embedded and FullScreen rebuilds the chat's DOM, because ChatRoot portals only in Embedded, but the service and transcript stay. - **In place, with a chat added or removed**: \`renderer\`. Switching between wind and material calls setRenderer on the same service and keeps the transcript. Choosing both adds a Material chat on its own service that copies the current transcript; leaving both removes it. - **Appends**: \`scenario\` scripts its traffic into the current transcript. Scenarios that need an empty transcript start a new chat first and say so: First-run experience, Long history with pagination. Changing scenario stops whatever the previous one still had scheduled (for example the rest of a stream). - **Remounts**: nothing driven by a control. Storybook's Remount button and the locale toolbar (which remounts every story) are the full reset: the chat keeps its service instance and starts it over from the story's controls. @@ -106,6 +106,12 @@ const meta = { 'Nested custom header actions: lucide icon names for Wind, Material Icons names for Material.', table: { category: 'Configuration' }, }, + autoScroll: { + control: 'boolean', + description: + 'chatService.toggleAutoScroll: follow new content while the reader is at the end.', + table: { category: 'Configuration' }, + }, preHooks: { control: 'boolean', description: 'Every pre-hook asks for confirmation before the action runs.', @@ -157,6 +163,7 @@ const meta = { firstRunExperience: true, customHeaderActions: true, preHooks: false, + autoScroll: true, disableClose: 'unset', ...featureArgs, title: '', diff --git a/packages/apollo-react/src/chat/stories/chat-playground.test.tsx b/packages/apollo-react/src/chat/stories/chat-playground.test.tsx index 8deca8e345..704eabbceb 100644 --- a/packages/apollo-react/src/chat/stories/chat-playground.test.tsx +++ b/packages/apollo-react/src/chat/stories/chat-playground.test.tsx @@ -1,5 +1,5 @@ import { act, render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AutopilotChatMode, AutopilotChatService } from '../index'; import { @@ -37,6 +37,7 @@ const baseProps = (): ChatPlaygroundProps => ({ firstRunExperience: false, customHeaderActions: false, preHooks: false, + autoScroll: true, scenario: 'none', autoReply: 'off', streamDelayMs: 1, @@ -84,6 +85,19 @@ describe('ChatPlayground', () => { ); }); + it('applies an initial autoScroll of false at mount', async () => { + const toggle = vi.spyOn(AutopilotChatService.prototype, 'toggleAutoScroll'); + try { + render(); + await flush(); + + expect(toggle).toHaveBeenCalledTimes(1); + expect(toggle).toHaveBeenCalledWith(false); + } finally { + toggle.mockRestore(); + } + }); + it('appends a scenario to the current transcript', async () => { const props = baseProps(); const { rerender } = render(); diff --git a/packages/apollo-react/src/chat/stories/chat-playground.tsx b/packages/apollo-react/src/chat/stories/chat-playground.tsx index 1f16dd481d..59c08beecb 100644 --- a/packages/apollo-react/src/chat/stories/chat-playground.tsx +++ b/packages/apollo-react/src/chat/stories/chat-playground.tsx @@ -94,6 +94,8 @@ export interface PlaygroundArgs extends FeatureFlagArgs { firstRunExperience: boolean; customHeaderActions: boolean; preHooks: boolean; + /** `toggleAutoScroll`: follow new content while at the end. */ + autoScroll: boolean; scenario: ChatStoryScenarioId; autoReply: PlaygroundAutoReply; streamDelayMs: number; @@ -295,6 +297,8 @@ function ChatSlot({ argsRef.current = args; const labelRef = React.useRef(label); labelRef.current = label; + const rendererRef = React.useRef(renderer); + rendererRef.current = renderer; const adoptHost = React.useCallback( (node: HTMLDivElement | null) => { @@ -358,6 +362,7 @@ function ChatSlot({ streamChunks( { service, + renderer: rendererRef.current, delayMs: streamDelayMs, schedule: timers.schedule, sendScriptedRequest: () => {}, @@ -411,6 +416,7 @@ function ChatSlot({ useOnChange(args.customHeaderActions, (enabled) => service.setCustomHeaderActions(customActionsFor(renderer, enabled)) ); + useOnChange(args.autoScroll, (enabled) => service.toggleAutoScroll(enabled)); useOnChange(args.preHooks, (enabled) => { for (const action of PRE_HOOK_ACTIONS) { service.setPreHook(action, enabled ? confirmPreHook(action) : allowPreHook); @@ -431,6 +437,7 @@ function ChatSlot({ const cleanups: Array<() => void> = []; const context: ScenarioContext = { service, + renderer: rendererRef.current, delayMs: argsRef.current.streamDelayMs, sendScriptedRequest: (request) => { const requestId = crypto.randomUUID(); @@ -453,6 +460,14 @@ function ChatSlot({ }; }, [args.scenario, id, registry, service, log]); + // Like `preHooks`, the initial value is applied at mount; after the scenario, whose new chat + // switches following back on. A later scripted turn switches it on again, as in a host app. + React.useEffect(() => { + if (!argsRef.current.autoScroll) { + service.toggleAutoScroll(false); + } + }, [service]); + const fullScreen = args.mode === AutopilotChatMode.FullScreen; return ( diff --git a/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts b/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts index 68ea0c2c3d..7bf43ad8db 100644 --- a/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts +++ b/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts @@ -418,6 +418,25 @@ export const RESPONSE_ACTIONS: AutopilotChatMessageAction[] = [ }, ]; +/** The same actions with lucide icon names, for the Wind renderer. */ +export const WIND_RESPONSE_ACTIONS: AutopilotChatMessageAction[] = RESPONSE_ACTIONS.map( + (action) => ({ + ...action, + icon: action.icon === 'content_copy' ? 'clipboard-copy' : 'refresh-cw', + }) +); + +/** An action for the "More" menu, with the icon name each renderer takes. */ +export function overflowResponseAction(renderer: 'wind' | 'material'): AutopilotChatMessageAction { + return { + name: 'share-action', + label: 'Share', + icon: renderer === 'wind' ? 'share-2' : 'share', + eventName: 'share-custom', + showInOverflow: true, + }; +} + export const CITATION_RESPONSE = { content: 'According to research, the sky is blue due to Rayleigh scattering. See references below for more information.', diff --git a/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts b/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts index 60f591eb47..0a9fc36166 100644 --- a/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts +++ b/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts @@ -12,6 +12,7 @@ import { AutopilotChatEvent, type AutopilotChatFileInfo, type AutopilotChatMessage, + type AutopilotChatRenderer, type AutopilotChatService, type ContentPartChunk, DEFAULT_MESSAGE_RENDERER, @@ -21,6 +22,7 @@ import { CODE_BLOCK_MESSAGE, HTML_PREVIEW_MESSAGE, MARKDOWN_CORPUS, + overflowResponseAction, RESOURCE_TOKEN_CHUNKS, RESOURCE_TOKEN_MESSAGE, RESPONSE_ACTIONS, @@ -31,10 +33,13 @@ import { storyHistoryPage, storyToolCallMeta, storyTraceSpan, + WIND_RESPONSE_ACTIONS, } from './chat-story-fixtures'; export interface ScenarioContext { service: AutopilotChatService; + /** The renderer this chat uses, for payloads whose icons differ per renderer. */ + renderer: AutopilotChatRenderer; /** Delay between streamed chunks and scripted turns. */ delayMs: number; /** Sends a user turn that neither triggers the auto reply nor is mirrored to the other chat. */ @@ -295,10 +300,10 @@ export const CHAT_STORY_SCENARIOS = { actions: { label: 'Message actions', description: - 'Appends a reply with custom actions (logged as copy-custom and regenerate-custom) and a reply with actions disabled.', + 'Appends a reply with custom actions (logged as copy-custom and regenerate-custom), one more in the "More" menu (share-custom), and a reply with actions disabled.', run: (context) => { const { service } = context; - const unsubscribes = RESPONSE_ACTIONS.map((action) => + const unsubscribes = [...RESPONSE_ACTIONS, overflowResponseAction('wind')].map((action) => service.on(action.eventName as AutopilotChatEvent, (data) => context.log(action.eventName ?? '', data) ) @@ -308,7 +313,10 @@ export const CHAT_STORY_SCENARIOS = { }); respond(service, { content: 'Here is a response with custom actions. Try the buttons below!', - actions: RESPONSE_ACTIONS, + actions: [ + ...(context.renderer === 'wind' ? WIND_RESPONSE_ACTIONS : RESPONSE_ACTIONS), + overflowResponseAction(context.renderer), + ], }); context.schedule( () => respond(service, { content: 'Message with no actions', disableActions: true }), diff --git a/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx b/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx index 2e88489487..a22cdc87e2 100644 --- a/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx +++ b/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx @@ -59,12 +59,12 @@ describe('WindChatRenderer', () => { render(); expect(getRoot()).not.toHaveAttribute('data-density'); - expect(screen.getByRole('banner')).toHaveClass('min-h-12'); + expect(screen.getByRole('banner')).toHaveClass('py-4'); act(() => service.patchConfig({ spacing: { compactMode: true } })); expect(getRoot()).toHaveAttribute('data-density', 'compact'); - expect(screen.getByRole('banner')).toHaveClass('min-h-10'); + expect(screen.getByRole('banner')).toHaveClass('py-2'); }); it('renders the title, preview badge and override labels', () => { diff --git a/packages/apollo-react/src/chat/wind/header/chat-header.tsx b/packages/apollo-react/src/chat/wind/header/chat-header.tsx index 843dcf335d..c41079639f 100644 --- a/packages/apollo-react/src/chat/wind/header/chat-header.tsx +++ b/packages/apollo-react/src/chat/wind/header/chat-header.tsx @@ -40,7 +40,13 @@ export function WindChatHeader({ className, layout }: { className?: string; layo data-separator={layout.showHeaderSeparator ? '' : undefined} className={cn( 'flex shrink-0 items-center justify-between gap-2 data-separator:border-b data-separator:border-border-de-emp', - compact ? 'min-h-10' : 'min-h-12', + // Material's header: a 32px row with 16px above and below (8px each when compact), or 8px + // above and a 4px gap over the separator. Heights include the padding. + layout.showHeaderSeparator + ? 'min-h-11 pt-2 pb-1' + : compact + ? 'min-h-12 py-2' + : 'min-h-16 py-4', className )} > diff --git a/packages/apollo-react/src/chat/wind/layout/embedded-layout.tsx b/packages/apollo-react/src/chat/wind/layout/embedded-layout.tsx index 7d24b7862b..a20301f941 100644 --- a/packages/apollo-react/src/chat/wind/layout/embedded-layout.tsx +++ b/packages/apollo-react/src/chat/wind/layout/embedded-layout.tsx @@ -1,7 +1,8 @@ import type { ChatLayout } from '../../headless/layout/chat-layout'; import { useChatState } from '../../headless/providers/chat-state-provider'; import { WindChatHeader } from '../header/chat-header'; -import { ComposerRegion, HistoryRegion, MessagesRegion, SettingsRegion } from './regions'; +import { MessageList } from '../messages/message-list'; +import { ComposerRegion, HistoryRegion, SettingsRegion } from './regions'; /** * The panel layout: header, messages and composer in one column, with history and settings @@ -9,21 +10,20 @@ import { ComposerRegion, HistoryRegion, MessagesRegion, SettingsRegion } from '. */ export function EmbeddedLayout({ layout }: { layout: ChatLayout }) { const { historyOpen, settingsOpen, spacing } = useChatState(); - const gutter = spacing.compactMode ? 'px-3' : 'px-4'; + // Material's panel gutter (SpacingL) in both densities, so the renderers line up. + const gutter = 'px-6'; return (
{layout.showHeader && }
-
- - {layout.showComposer && } +
+ + {layout.showComposer && ( +
+ +
+ )}
{layout.historyAvailable && historyOpen && (
diff --git a/packages/apollo-react/src/chat/wind/layout/full-screen-layout.tsx b/packages/apollo-react/src/chat/wind/layout/full-screen-layout.tsx index d772d685eb..b58090f15f 100644 --- a/packages/apollo-react/src/chat/wind/layout/full-screen-layout.tsx +++ b/packages/apollo-react/src/chat/wind/layout/full-screen-layout.tsx @@ -4,7 +4,8 @@ import type { ChatLayout } from '../../headless/layout/chat-layout'; import { useChatState } from '../../headless/providers/chat-state-provider'; import { CHAT_DRAWER_WIDTH_FULL_SCREEN, CHAT_WIDTH_FULL_SCREEN_MAX_WIDTH } from '../../service'; import { WindChatHeader } from '../header/chat-header'; -import { ComposerRegion, HistoryRegion, MessagesRegion, SettingsRegion } from './regions'; +import { MessageList } from '../messages/message-list'; +import { ComposerRegion, HistoryRegion, SettingsRegion } from './regions'; const sizes = { '--chat-column-max-width': CHAT_WIDTH_FULL_SCREEN_MAX_WIDTH, @@ -16,17 +17,19 @@ const sizes = { * on the right. */ export function FullScreenLayout({ layout }: { layout: ChatLayout }) { - const { historyOpen, settingsOpen, spacing } = useChatState(); + const { historyOpen, settingsOpen } = useChatState(); return (
- +
-
-
- - {layout.showComposer && } -
+
+ + {layout.showComposer && ( +
+ +
+ )}
{layout.historyAvailable && historyOpen && (