From be7fcd3361a74ef26ec06cd06ff8fd12a1397b6d Mon Sep 17 00:00:00 2001 From: BenGSchulz Date: Thu, 15 Oct 2026 14:56:40 -0700 Subject: [PATCH] feat(apollo-react): add the Wind chat composer Replace the Wind composer placeholder with apollo-wind's ChatComposer wired to the service as Material's input is: the draft follows the service prompt, Enter or the send button sends through the Request intercept with the attachments and returns focus to the field, the send button stops an in-flight reply unless stopResponse is disabled, the service error shows above the field as a dismissible markdown alert, and the footer disclaimer comes from the shared labels. Attachments come in through the attach button and a panel-wide DropzoneOverlay (drop and paste), checked against allowedAttachments. Hoist Material's dropzone validation and messages into useAttachmentIntake and its send and stop logic into useComposerSend, and switch Material's dropzone and input to them. MST-16566 Co-Authored-By: Claude Opus 5.5 --- .../hooks/use-attachment-intake.test.tsx | 117 ++++++ .../headless/hooks/use-attachment-intake.ts | 169 ++++++++ .../headless/hooks/use-attachment-limits.ts | 45 +++ .../headless/hooks/use-composer-send.test.tsx | 66 ++++ .../chat/headless/hooks/use-composer-send.ts | 40 ++ .../apollo-react/src/chat/locales/en.json | 4 +- .../src/chat/stories/chat-story-scenarios.ts | 18 +- .../src/chat/wind/WindChatRenderer.test.tsx | 3 +- .../src/chat/wind/WindChatRenderer.tsx | 6 +- .../chat/wind/composer/chat-composer.test.tsx | 363 ++++++++++++++++++ .../src/chat/wind/composer/chat-composer.tsx | 212 ++++++++++ .../src/chat/wind/composer/chat-dropzone.tsx | 102 +++++ .../chat/wind/composer/composer-strings.ts | 29 ++ .../src/chat/wind/layout/regions.tsx | 29 +- .../src/chat/wind/messages/chat-message.tsx | 16 +- .../wind/messages/message-attachments.tsx | 86 ++++- .../chat/wind/messages/message-list.test.tsx | 43 +++ .../chat/wind/renderers/markdown-renderer.tsx | 3 +- .../components/dropzone/dropzone.test.ts | 14 + .../ap-chat/components/dropzone/dropzone.tsx | 147 ++----- .../components/input/chat-input-actions.tsx | 155 ++------ .../components/input/chat-input.test.tsx | 28 ++ .../ap-chat/components/input/chat-input.tsx | 34 +- 23 files changed, 1399 insertions(+), 330 deletions(-) create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-attachment-intake.test.tsx create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-attachment-intake.ts create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-attachment-limits.ts create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-composer-send.test.tsx create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-composer-send.ts create mode 100644 packages/apollo-react/src/chat/wind/composer/chat-composer.test.tsx create mode 100644 packages/apollo-react/src/chat/wind/composer/chat-composer.tsx create mode 100644 packages/apollo-react/src/chat/wind/composer/chat-dropzone.tsx create mode 100644 packages/apollo-react/src/chat/wind/composer/composer-strings.ts create mode 100644 packages/apollo-react/src/material/components/ap-chat/components/dropzone/dropzone.test.ts diff --git a/packages/apollo-react/src/chat/headless/hooks/use-attachment-intake.test.tsx b/packages/apollo-react/src/chat/headless/hooks/use-attachment-intake.test.tsx new file mode 100644 index 000000000..3aa9aa76f --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-attachment-intake.test.tsx @@ -0,0 +1,117 @@ +import { act, render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { ApI18nProvider } from '../../../i18n'; +import { type AutopilotChatConfiguration, AutopilotChatService } from '../../service'; +import { AutopilotAttachmentsProvider, useAttachments } from '../providers/attachments-provider'; +import { AutopilotChatServiceProvider } from '../providers/chat-service.provider'; +import { AutopilotChatStateProvider } from '../providers/chat-state-provider'; +import { AutopilotErrorProvider, useError } from '../providers/error-provider'; +import { + type ChatAttachmentRejection, + checkAttachmentFiles, + getAcceptedFileTypes, + useAttachmentIntake, +} from './use-attachment-intake'; + +let instanceCounter = 0; +let take: ReturnType | undefined; +let state: { names: string[]; error?: string } = { names: [] }; + +function Probe() { + take = useAttachmentIntake(); + const { attachments } = useAttachments(); + const { error } = useError(); + state = { names: attachments.map(({ name }) => name), error: error?.message }; + return null; +} + +function setup(allowedAttachments: AutopilotChatConfiguration['allowedAttachments']) { + const service = AutopilotChatService.Instantiate({ + instanceName: `intake-${++instanceCounter}`, + config: { allowedAttachments }, + }); + render( + + + + + + + + + + + + ); +} + +const file = (name: string, size = 10) => + new File(['x'.repeat(size)], name, { type: 'text/plain', lastModified: 1 }); +const reject = (name: string, reason: ChatAttachmentRejection['reason']) => ({ + file: file(name), + reason, +}); +const types = { 'text/plain': ['.txt'] }; + +describe('useAttachmentIntake', () => { + it('accepts every MIME type and extension of the allowed types', () => { + expect(getAcceptedFileTypes({ 'text/plain': ['.txt'], 'image/png': ['.png'] })).toEqual([ + 'text/plain', + 'image/png', + '.txt', + '.png', + ]); + }); + + it('checks picked files by MIME type or extension, size, then the count left', () => { + const allowed = { multiple: true, types, maxSize: 15, maxCount: 3 }; + const picked = [ + new File(['x'], 'a.TXT', { type: '' }), + new File(['x'], 'b.bin', { type: 'text/plain' }), + new File(['x'], 'c.exe', { type: 'application/x-msdownload' }), + new File(['x'.repeat(20)], 'd.txt', { type: 'text/plain' }), + new File(['x'], 'e.txt', { type: 'text/plain' }), + ]; + + const { accepted, rejected } = checkAttachmentFiles(picked, allowed, 1); + + expect(accepted.map(({ name }) => name)).toEqual(['a.TXT', 'b.bin']); + expect(rejected.map(({ file, reason }) => [file.name, reason])).toEqual([ + ['c.exe', 'type'], + ['d.txt', 'size'], + ['e.txt', 'count'], + ]); + }); + + it('attaches accepted files and names the rejected ones', async () => { + setup({ multiple: true, types, maxSize: 1024, maxCount: 5 }); + + await act(() => take?.([file('a.txt')], [reject('big.txt', 'size'), reject('x.exe', 'type')])); + + expect(state.names).toEqual(['a.txt']); + expect(state.error).toMatch(/big\.txt/); + expect(state.error).toMatch(/x\.exe/); + }); + + it('rejects the whole batch when there are too many files', async () => { + setup({ multiple: true, types, maxSize: 1024, maxCount: 1 }); + + await act(() => take?.([file('a.txt')], [reject('b.txt', 'count')])); + + expect(state.names).toEqual([]); + expect(state.error).toBeTruthy(); + }); + + it('allows one file at a time when multiple is off', async () => { + setup({ multiple: false, types, maxSize: 1024 }); + + await act(() => take?.([file('a.txt'), file('b.txt')], [])); + expect(state.names).toEqual([]); + expect(state.error).toBeTruthy(); + + await act(() => take?.([file('a.txt')], [])); + expect(state.names).toEqual(['a.txt']); + expect(state.error).toBeUndefined(); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/hooks/use-attachment-intake.ts b/packages/apollo-react/src/chat/headless/hooks/use-attachment-intake.ts new file mode 100644 index 000000000..6d5332ea2 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-attachment-intake.ts @@ -0,0 +1,169 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import React from 'react'; + +import type { AutopilotChatAllowedAttachments, AutopilotChatFileInfo } from '../../service'; +import { useAttachments } from '../providers/attachments-provider'; +import { useChatState } from '../providers/chat-state-provider'; +import { useError } from '../providers/error-provider'; +import { parseFiles } from '../utils/file-reader'; + +/** + * Why a dropped, pasted or picked file was turned away, whatever library checked it. `other` + * counts as a rejection but has no message of its own. + */ +export type ChatAttachmentRejectionReason = 'type' | 'size' | 'count' | 'other'; + +export interface ChatAttachmentRejection { + file: File; + reason: ChatAttachmentRejectionReason; +} + +/** + * The `accept` entries for `allowedAttachments.types` (MIME type to extensions): every MIME type + * and every extension, so a file a browser mislabels still matches by its extension. + */ +export function getAcceptedFileTypes(types: AutopilotChatAllowedAttachments['types']): string[] { + return [...Object.keys(types), ...Object.values(types).flat()]; +} + +/** + * Checks picked files against `allowedAttachments` the way the drop targets do: a type matches by + * MIME type or extension, then size, then the count left after `existingCount`. + */ +export function checkAttachmentFiles( + files: readonly File[], + allowed: AutopilotChatAllowedAttachments, + existingCount = 0 +): { accepted: File[]; rejected: ChatAttachmentRejection[] } { + const accept = getAcceptedFileTypes(allowed.types).map((entry) => entry.toLowerCase()); + const remaining = + allowed.maxCount === undefined ? Number.POSITIVE_INFINITY : allowed.maxCount - existingCount; + const accepted: File[] = []; + const rejected: ChatAttachmentRejection[] = []; + + for (const file of files) { + const name = file.name.toLowerCase(); + const typeAccepted = + accept.length === 0 || + accept.some((entry) => + entry.startsWith('.') ? name.endsWith(entry) : entry === file.type.toLowerCase() + ); + if (!typeAccepted) { + rejected.push({ file, reason: 'type' }); + } else if (file.size > allowed.maxSize) { + rejected.push({ file, reason: 'size' }); + } else if (accepted.length >= remaining) { + rejected.push({ file, reason: 'count' }); + } else { + accepted.push(file); + } + } + return { accepted, rejected }; +} + +/** + * Takes files checked against `allowedAttachments` into the draft, as Material's dropzone does: + * a second file when only one is allowed, or more than `maxCount`, rejects the whole batch with + * one message; oversized and wrongly typed files are named in the error and the rest are + * attached. Clears the error when nothing was rejected. + */ +export function useAttachmentIntake() { + const { _ } = useLingui(); + const { addAttachments, attachments } = useAttachments(); + const { setError } = useError(); + const { allowedAttachments } = useChatState(); + const attachmentsCount = React.useRef(attachments.length); + attachmentsCount.current = attachments.length; + + const handleRejections = React.useCallback( + (parsedFiles: AutopilotChatFileInfo[], rejections: ChatAttachmentRejection[]) => { + if ( + !allowedAttachments.multiple && + (parsedFiles.length > 1 || attachmentsCount.current > 0 || rejections.length > 1) + ) { + setError( + _( + msg({ + id: 'autopilot-chat.error.multiple-files', + message: `Only one file can be attached at a time`, + }) + ) + ); + return true; + } + + if (rejections.some(({ reason }) => reason === 'count')) { + const maxCount = allowedAttachments.maxCount; + setError( + _( + msg({ + id: 'autopilot-chat.error.too-many-files', + message: `Maximum ${maxCount} files allowed`, + }) + ) + ); + return true; + } + + const errorMessages: string[] = []; + for (const { reason, file } of rejections) { + const fileName = file.name; + if (reason === 'size') { + const fileSizeMB = Math.round(file.size / 1024 / 1024); + const maxSize = allowedAttachments.maxSize / 1024 / 1024; + errorMessages.push( + _( + msg({ + id: 'autopilot-chat.error.file-too-large', + message: `${fileName} (${fileSizeMB}MB) exceeds maximum size of ${maxSize}MB`, + }) + ) + ); + } else if (reason === 'type') { + errorMessages.push( + _( + msg({ + id: 'autopilot-chat.error.file-invalid-type', + message: `${fileName} has an invalid file type`, + }) + ) + ); + } + } + if (errorMessages.length > 0) { + setError(errorMessages.join('\n')); + } + return false; + }, + [ + _, + setError, + allowedAttachments.maxCount, + allowedAttachments.maxSize, + allowedAttachments.multiple, + ] + ); + + return React.useCallback( + async (accepted: File[], rejected: ChatAttachmentRejection[]) => { + try { + const parsedFiles = await parseFiles(accepted); + if (handleRejections(parsedFiles, rejected)) { + return; + } + if (rejected.length === 0) { + setError(undefined); + } + if (allowedAttachments.multiple) { + addAttachments(parsedFiles); + } else if (parsedFiles.length === 1) { + addAttachments([parsedFiles[0] as AutopilotChatFileInfo]); + } + } catch (error) { + setError(error as string); + } + }, + [addAttachments, setError, allowedAttachments.multiple, handleRejections] + ); +} diff --git a/packages/apollo-react/src/chat/headless/hooks/use-attachment-limits.ts b/packages/apollo-react/src/chat/headless/hooks/use-attachment-limits.ts new file mode 100644 index 000000000..44a6bec93 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-attachment-limits.ts @@ -0,0 +1,45 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import React from 'react'; + +import type { AutopilotChatAllowedAttachments } from '../../service'; + +/** + * The attach limits as both renderers list them by the attach button: the most files (when more + * than one is allowed), the largest size, and the allowed extensions. + */ +export function useAttachmentLimits(allowed: AutopilotChatAllowedAttachments): string[] { + const { _ } = useLingui(); + + return React.useMemo(() => { + const lines: string[] = []; + const maxCount = allowed.maxCount; + const maxSize = allowed.maxSize / 1024 / 1024; + const fileTypes = Object.values(allowed.types).flat().join(', '); + if (maxCount && maxCount > 1 && allowed.multiple) { + lines.push( + _( + msg({ + id: 'autopilot-chat.input.actions.attachments.max-count', + message: `Maximum ${maxCount} files`, + }) + ) + ); + } + lines.push( + _( + msg({ + id: 'autopilot-chat.input.actions.attachments.max-size', + message: `Maximum ${maxSize}MB per file`, + }) + ), + _( + msg({ + id: 'autopilot-chat.input.actions.attachments.allowed-types', + message: `Allowed types: ${fileTypes}`, + }) + ) + ); + return lines; + }, [_, allowed]); +} diff --git a/packages/apollo-react/src/chat/headless/hooks/use-composer-send.test.tsx b/packages/apollo-react/src/chat/headless/hooks/use-composer-send.test.tsx new file mode 100644 index 000000000..1929e6e67 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-composer-send.test.tsx @@ -0,0 +1,66 @@ +import { act, render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { AutopilotChatEvent, AutopilotChatService } from '../../service'; +import { AutopilotChatServiceProvider } from '../providers/chat-service.provider'; +import { AutopilotChatStateProvider } from '../providers/chat-state-provider'; +import { AutopilotLoadingProvider } from '../providers/loading-provider'; +import { AutopilotStreamingProvider } from '../providers/streaming-provider'; +import { useComposerSend } from './use-composer-send'; + +let instanceCounter = 0; +let composer: ReturnType | undefined; + +function Probe() { + composer = useComposerSend(); + return null; +} + +function setup() { + const service = AutopilotChatService.Instantiate({ instanceName: `send-${++instanceCounter}` }); + const events: string[] = []; + for (const event of [ + AutopilotChatEvent.SpeechToTextToggle, + AutopilotChatEvent.Request, + AutopilotChatEvent.StopResponse, + ]) { + service.on(event, () => events.push(event)); + } + render( + + + + + + + + + + ); + return { service, events }; +} + +describe('useComposerSend', () => { + it('ends dictation before sending', async () => { + const { service, events } = setup(); + service.setSpeechToTextState(true); + + // Request reaches listeners after the interceptors, a tick later. + await act(async () => composer?.send('Hi', [])); + + expect(events).toEqual([AutopilotChatEvent.SpeechToTextToggle, AutopilotChatEvent.Request]); + expect(service.isSpeechToTextActive).toBe(false); + }); + + it('stops a reply without touching dictation', () => { + const { service, events } = setup(); + act(() => service.setWaiting(true)); + service.setSpeechToTextState(true); + + expect(composer?.canStopResponse).toBe(true); + act(() => composer?.stop()); + + expect(events).toEqual([AutopilotChatEvent.StopResponse]); + expect(service.isSpeechToTextActive).toBe(true); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/hooks/use-composer-send.ts b/packages/apollo-react/src/chat/headless/hooks/use-composer-send.ts new file mode 100644 index 000000000..33f5a0fd2 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-composer-send.ts @@ -0,0 +1,40 @@ +import React from 'react'; + +import type { AutopilotChatFileInfo } from '../../service'; +import { useChatService } from '../providers/chat-service.provider'; +import { useChatState } from '../providers/chat-state-provider'; +import { useLoading } from '../providers/loading-provider'; +import { useStreaming } from '../providers/streaming-provider'; + +/** + * Sending and stopping from a composer, as both renderers do. While a reply is awaited or + * streams, the send control stops it instead, unless `disabledFeatures.stopResponse` is set. + */ +export function useComposerSend() { + const chatService = useChatService(); + const { disabledFeatures } = useChatState(); + const { waitingResponse } = useLoading(); + const { streaming } = useStreaming(); + const isResponseInFlight = waitingResponse || streaming; + const canStopResponse = isResponseInFlight && !disabledFeatures?.stopResponse; + + const send = React.useCallback( + (content: string, attachments: AutopilotChatFileInfo[]) => { + // Stop dictation first so its growing transcript cannot refill the cleared input. The + // toggle, not setSpeechToTextState, because consumers tear the recognizer down on it. + if (chatService.isSpeechToTextActive) { + chatService.publishSpeechToTextToggle(); + } + chatService.sendRequest({ content, attachments }); + }, + [chatService] + ); + + const stop = React.useCallback(() => { + if (canStopResponse) { + chatService.stopResponse(); + } + }, [chatService, canStopResponse]); + + return { isResponseInFlight, canStopResponse, send, stop }; +} diff --git a/packages/apollo-react/src/chat/locales/en.json b/packages/apollo-react/src/chat/locales/en.json index 056484185..b01b209ed 100644 --- a/packages/apollo-react/src/chat/locales/en.json +++ b/packages/apollo-react/src/chat/locales/en.json @@ -114,5 +114,7 @@ "autopilot-chat.tool-call.json.copy-path-hint": "Click to copy this path", "autopilot-chat.tool-call.json.copy-path-for": "Copy path for {path}", "autopilot-chat.tool-call.json.copy-value": "Copy value", - "autopilot-chat.tool-call.json.copy-value-of": "Copy value of {key}" + "autopilot-chat.tool-call.json.copy-value-of": "Copy value of {key}", + "autopilot-chat.input.label": "Message", + "autopilot-chat.input.error.dismiss": "Dismiss" } 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 a713ac338..3aea4909e 100644 --- a/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts +++ b/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts @@ -322,7 +322,8 @@ export const CHAT_STORY_SCENARIOS = { }, attachments: { label: 'Attachments', - description: 'Appends a user turn with two attached files and a reply.', + description: + 'Appends a user turn with two attached files and a reply, then puts a draft with an attachment in the composer (setPrompt). Attach more with the button, by dropping files on the panel or by pasting them; the default limits allow common document and image types up to the configured size and count. Clear chat or a scenario change clears the draft text, but not the attachment chip: the service has no call that removes a draft attachment, so remove it with its button.', run: (context) => { context.sendScriptedRequest({ content: "Here's the invoice batch for this week. Which ones are ready?", @@ -335,6 +336,15 @@ export const CHAT_STORY_SCENARIOS = { }), context.delayMs ); + context.schedule( + () => + context.service.setPrompt({ + content: 'Which vendors are in this file?', + attachments: STORY_ATTACHMENTS.slice(1), + }), + context.delayMs * 2 + ); + context.onCleanup(() => context.service.setPrompt('')); }, }, actions: { @@ -381,7 +391,8 @@ export const CHAT_STORY_SCENARIOS = { }, error: { label: 'Error', - description: 'Shows a chat error (markdown supported) until the scenario changes.', + description: + 'Shows a chat error (markdown supported) above the composer until it is dismissed or the scenario changes.', run: (context) => { context.service.setError('An error occurred. **Please try again.**'); context.onCleanup(() => context.service.clearError()); @@ -389,7 +400,8 @@ export const CHAT_STORY_SCENARIOS = { }, warning: { label: 'Warning', - description: 'Shows a chat warning until the scenario changes.', + description: + 'Shows a chat warning above the composer until it is dismissed or the scenario changes.', run: (context) => { context.service.setError('This is a warning message.', 'warn'); context.onCleanup(() => context.service.clearError()); diff --git a/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx b/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx index c1bca42f6..4411883cf 100644 --- a/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx +++ b/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx @@ -195,7 +195,8 @@ describe('WindChatRenderer', () => { service.setOverrideLabels({ inputPlaceholder: 'Ask the agent', footerDisclaimer: 'Be kind' }) ); - expect(document.querySelector('[data-slot="wind-chat-composer"]')).toHaveTextContent( + expect(screen.getByRole('textbox', { name: 'Message' })).toHaveAttribute( + 'placeholder', 'Ask the agent' ); expect(screen.getByText('Be kind')).toBeInTheDocument(); diff --git a/packages/apollo-react/src/chat/wind/WindChatRenderer.tsx b/packages/apollo-react/src/chat/wind/WindChatRenderer.tsx index cd24a9196..8b7c8d96e 100644 --- a/packages/apollo-react/src/chat/wind/WindChatRenderer.tsx +++ b/packages/apollo-react/src/chat/wind/WindChatRenderer.tsx @@ -8,6 +8,7 @@ import { useChatWidth } from '../headless/providers/chat-width-provider'; import { useTheme } from '../headless/providers/theme-provider'; import { AutopilotChatMode, CHAT_CONTAINER_ANIMATION_DURATION } from '../service'; import { DialogContainerContext } from './common/dialog-container'; +import { WindChatDropzone } from './composer/chat-dropzone'; import { useOpenTransition } from './hooks/use-open-transition'; import { EmbeddedLayout } from './layout/embedded-layout'; import { FullScreenLayout } from './layout/full-screen-layout'; @@ -97,7 +98,10 @@ export function WindChatRenderer() { - + {/* Panel-wide, as Material's dropzone; off where there is no composer to attach to. */} + + + diff --git a/packages/apollo-react/src/chat/wind/composer/chat-composer.test.tsx b/packages/apollo-react/src/chat/wind/composer/chat-composer.test.tsx new file mode 100644 index 000000000..99dade106 --- /dev/null +++ b/packages/apollo-react/src/chat/wind/composer/chat-composer.test.tsx @@ -0,0 +1,363 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { + ApChat, + type AutopilotChatConfiguration, + AutopilotChatInterceptableEvent, + type AutopilotChatMessage, + AutopilotChatMode, + type AutopilotChatRenderer, + AutopilotChatService, +} from '../../index'; + +let instanceCounter = 0; +const createService = ( + config: Partial = {}, + renderer: AutopilotChatRenderer = 'wind' +) => + AutopilotChatService.Instantiate({ + instanceName: `wind-composer-${++instanceCounter}`, + config: { mode: AutopilotChatMode.FullScreen, renderer, ...config }, + }); + +function setup(config: Partial = {}) { + const service = createService(config); + const requests: AutopilotChatMessage[] = []; + service.intercept(AutopilotChatInterceptableEvent.Request, (message: AutopilotChatMessage) => { + requests.push(message); + }); + render(); + return { service, requests }; +} + +const textbox = () => screen.getByRole('textbox', { name: 'Message' }); +// The attach button opens the composer's file input; the pick then goes through its checks. +async function pick(...files: File[]) { + await userEvent.click(screen.getByRole('button', { name: 'Attach file(s)' })); + const input = document.querySelector( + '[data-slot="chat-composer-file-input"]' + ) as HTMLInputElement; + await userEvent.upload(input, files); +} + +const file = (name: string, type: string, size = 10) => + new File(['x'.repeat(size)], name, { type, lastModified: 1 }); + +describe('Wind composer', () => { + it('sends with Enter, keeps Shift+Enter for a new line, and clears the draft', async () => { + const { requests } = setup(); + + await userEvent.type(textbox(), 'Hello{Shift>}{Enter}{/Shift}there'); + expect(textbox()).toHaveValue('Hello\nthere'); + expect(requests).toHaveLength(0); + + await userEvent.keyboard('{Enter}'); + + await waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0]).toMatchObject({ content: 'Hello\nthere', attachments: [] }); + expect(textbox()).toHaveValue(''); + }); + + it('switches the button from send to stop in one step, without a style change', async () => { + setup(); + await userEvent.type(textbox(), 'Hi'); + const button = () => + document.querySelector('[data-slot="chat-composer-submit"]') as HTMLButtonElement; + const classes = button().className; + const statuses: Array = []; + const observer = new MutationObserver(() => { + const status = button().getAttribute('data-status'); + if (statuses.at(-1) !== status) statuses.push(status); + }); + observer.observe(document.body, { subtree: true, attributes: true, childList: true }); + + await userEvent.keyboard('{Enter}'); + await act(() => new Promise((resolve) => setTimeout(resolve, 50))); + observer.disconnect(); + + expect(statuses).toEqual(['streaming']); + expect(button()).toHaveAccessibleName('Stop'); + expect(button().className).toBe(classes); + }); + + it('lets an uploading file be removed, and does not bring it back when the upload ends', async () => { + const { service } = setup(); + const removed: unknown[] = []; + service.on('setAttachments', ({ removed: files }: { removed: unknown[] }) => + removed.push(...files) + ); + await pick(file('slow.txt', 'text/plain')); + act(() => + service.setAttachmentsLoading([ + { name: 'slow.txt', size: 10, loading: true } as Parameters< + typeof service.setAttachmentsLoading + >[0][number], + ]) + ); + expect(document.querySelector('[data-slot="attachment"]')).toHaveAttribute( + 'data-state', + 'uploading' + ); + + await userEvent.click(screen.getByRole('button', { name: 'Remove slow.txt' })); + act(() => service.setAttachmentsLoading([])); + + expect(removed).toHaveLength(1); + expect(document.querySelector('[data-slot="chat-composer-attachments"]')).toBeNull(); + }); + + it('does not send with Enter while a reply is in flight and stop is disabled', async () => { + const { service, requests } = setup({ disabledFeatures: { stopResponse: true } }); + act(() => service.setWaiting(true)); + + await userEvent.type(textbox(), 'Too soon{Enter}'); + + expect(requests).toHaveLength(0); + expect(textbox()).toHaveValue('Too soon'); + }); + + it('returns focus to the field after sending with the button', async () => { + const { requests } = setup(); + await userEvent.type(textbox(), 'Hi'); + + await userEvent.click(screen.getByRole('button', { name: 'Send' })); + + await waitFor(() => expect(requests).toHaveLength(1)); + expect(textbox()).toHaveFocus(); + }); + + it('follows the service prompt', () => { + const { service } = setup(); + + act(() => service.setPrompt('From the host')); + + expect(textbox()).toHaveValue('From the host'); + }); + + it('turns send into stop while a reply is awaited, unless stopResponse is disabled', async () => { + const { service } = setup(); + let stopped = 0; + service.on('stopResponse', () => { + stopped += 1; + }); + await userEvent.type(textbox(), 'Hi{Enter}'); + + await userEvent.click(await screen.findByRole('button', { name: 'Stop' })); + expect(stopped).toBe(1); + + act(() => service.setDisabledFeatures({ stopResponse: true })); + await userEvent.type(textbox(), 'Again{Enter}'); + expect(await screen.findByRole('button', { name: 'Send' })).toBeDisabled(); + }); + + it('shows the service error as an alert, as markdown, and dismisses it', async () => { + const { service } = setup(); + + act(() => service.setError('Something went **wrong**.')); + const alert = screen.getByRole('alert'); + expect(alert).toHaveAttribute('data-variant', 'error'); + expect(within(alert).getByText('wrong').tagName).toBe('STRONG'); + + act(() => service.setError('Careful.', 'warn')); + expect(screen.getByRole('alert')).toHaveAttribute('data-variant', 'warning'); + + await userEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(screen.queryByRole('alert')).toBeNull(); + expect(service.getError()).toBeUndefined(); + // Focus stays in the composer rather than dropping to the page. + expect(textbox()).toHaveFocus(); + }); + + it('attaches picked files and sends them with the prompt', async () => { + const { requests } = setup(); + + await pick(file('notes.txt', 'text/plain')); + + expect(await screen.findByText('notes.txt')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove notes.txt' })).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0]?.attachments?.map(({ name }) => name)).toEqual(['notes.txt']); + expect(document.querySelector('[data-slot="chat-composer-attachments"]')).toBeNull(); + }); + + it('names files that are too large, as Material does', async () => { + setup({ + allowedAttachments: { multiple: true, types: { 'text/plain': ['.txt'] }, maxSize: 5 }, + }); + + await pick(file('big.txt', 'text/plain', 20)); + + expect(await screen.findByRole('alert')).toHaveTextContent('big.txt'); + expect(screen.queryByRole('button', { name: /Remove big\.txt/ })).toBeNull(); + }); + + it('takes files dropped or pasted anywhere on the panel', async () => { + setup(); + const panel = document.querySelector('[data-slot="wind-chat-dropzone"]') as HTMLElement; + + fireEvent.drop(panel, { + dataTransfer: { types: ['Files'], files: [file('dropped.txt', 'text/plain')], items: [] }, + }); + expect(await screen.findByText('dropped.txt')).toBeInTheDocument(); + + fireEvent.paste(panel, { + clipboardData: { types: ['Files'], files: [file('pasted.txt', 'text/plain')], items: [] }, + }); + expect(await screen.findByText('pasted.txt')).toBeInTheDocument(); + }); + + it('hides the attach button and ignores drops when attachments are disabled', async () => { + setup({ disabledFeatures: { attachments: true } }); + + expect(screen.queryByRole('button', { name: 'Attach file(s)' })).toBeNull(); + const panel = document.querySelector('[data-slot="wind-chat-dropzone"]') as HTMLElement; + fireEvent.drop(panel, { + dataTransfer: { types: ['Files'], files: [file('dropped.txt', 'text/plain')], items: [] }, + }); + await act(() => new Promise((resolve) => setTimeout(resolve, 50))); + expect(screen.queryByText('dropped.txt')).toBeNull(); + }); + + it('is compact with spacing.compactMode and shows the footer disclaimer at 10px', () => { + setup({ spacing: { compactMode: true } }); + + expect(document.querySelector('[data-slot="chat-composer"]')).toHaveAttribute( + 'data-density', + 'compact' + ); + expect(document.querySelector('[data-slot="chat-composer-footer"]')).toHaveClass('text-[10px]'); + }); + + it("keeps a sent message's attachment row inside the message so it scrolls", async () => { + const { requests } = setup(); + await pick(file('one.txt', 'text/plain'), file('two.txt', 'text/plain')); + await userEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => expect(requests).toHaveLength(1)); + + const row = document.querySelector( + '[data-chat-message-id] [data-slot="wind-chat-message-attachments"]' + ) as HTMLElement; + // An end-aligned part sizes to its content; the cap keeps the row within the message. + expect(row.parentElement).toHaveClass('max-w-full'); + expect(row.querySelector('[data-slot="attachment"]')).toHaveClass('max-w-[calc(50cqw-4px)]'); + }); + + it('spaces draft chips like sent ones', async () => { + setup(); + await pick(file('one.txt', 'text/plain'), file('two.txt', 'text/plain')); + await screen.findByText('two.txt'); + + const draft = document.querySelector('[data-slot="chat-composer-attachments"]'); + expect(draft).toHaveClass('gap-2'); + expect(draft).not.toHaveClass('gap-3'); + }); +}); + +describe('Wind composer attachment intake', () => { + const panel = () => document.querySelector('[data-slot="wind-chat-dropzone"]') as HTMLElement; + const chips = () => + [ + ...document.querySelectorAll( + '[data-slot="chat-composer-attachments"] [data-slot="attachment"]' + ), + ].map((chip) => chip.textContent); + const dropFiles = (...files: File[]) => + fireEvent.drop(panel(), { dataTransfer: { types: ['Files'], files, items: [] } }); + const pasteFiles = (text: string, ...files: File[]) => + fireEvent.paste(panel(), { + clipboardData: { types: ['text/plain', 'Files'], files, items: [], getData: () => text }, + }); + + it('catches a file dropped on the panel while it cannot take it', () => { + setup({ disabledFeatures: { attachments: true } }); + + // fireEvent returns false when the default (opening the file in the tab) was prevented. + expect(dropFiles(file('a.txt', 'text/plain'))).toBe(false); + expect( + fireEvent.dragOver(panel(), { dataTransfer: { types: ['Files'], files: [], items: [] } }) + ).toBe(false); + }); + + it('keeps the text of a paste whose file is refused, and names the file', async () => { + setup({ + allowedAttachments: { multiple: true, types: { 'text/plain': ['.txt'] }, maxSize: 1024 }, + }); + + expect(pasteFiles('some text', file('tool.exe', 'application/x-msdownload'))).toBe(true); + + expect(await screen.findByRole('alert')).toHaveTextContent('tool.exe'); + expect(chips()).toEqual([]); + }); + + it('takes a pasted file that passes, instead of the text', async () => { + setup({ + allowedAttachments: { multiple: true, types: { 'text/plain': ['.txt'] }, maxSize: 1024 }, + }); + + expect(pasteFiles('some text', file('notes.txt', 'text/plain'))).toBe(false); + + await waitFor(() => expect(chips()).toEqual(['notes.txt'])); + }); + + it('counts files from every source against maxCount', async () => { + setup({ + allowedAttachments: { + multiple: true, + types: { 'text/plain': ['.txt'] }, + maxSize: 1024, + maxCount: 3, + }, + }); + await pick(file('a.txt', 'text/plain'), file('b.txt', 'text/plain')); + await waitFor(() => expect(chips()).toEqual(['a.txt', 'b.txt'])); + + dropFiles(file('c.txt', 'text/plain'), file('d.txt', 'text/plain')); + + expect(await screen.findByRole('alert')).toBeInTheDocument(); + expect(chips()).toEqual(['a.txt', 'b.txt']); + }); + + it('reads maxCount 0 as no limit, as Material does', async () => { + setup({ + allowedAttachments: { + multiple: true, + types: { 'text/plain': ['.txt'] }, + maxSize: 1024, + maxCount: 0, + }, + }); + + await pick(file('a.txt', 'text/plain'), file('b.txt', 'text/plain')); + dropFiles(file('c.txt', 'text/plain')); + + await waitFor(() => expect(chips()).toEqual(['a.txt', 'b.txt', 'c.txt'])); + }); +}); + +describe('composer parity', () => { + async function sendFrom(renderer: AutopilotChatRenderer) { + const service = createService({}, renderer); + const requests: AutopilotChatMessage[] = []; + service.intercept(AutopilotChatInterceptableEvent.Request, (message: AutopilotChatMessage) => { + requests.push(message); + }); + const { unmount } = render(); + act(() => service.setPrompt('Same prompt')); + + await userEvent.click(await screen.findByRole('button', { name: 'Send' }, { timeout: 25_000 })); + await waitFor(() => expect(requests).toHaveLength(1)); + unmount(); + const { id: _id, created_at: _created, ...payload } = requests[0] as AutopilotChatMessage; + return payload; + } + + it('sends the same Request payload from Wind and Material', async () => { + const material = await sendFrom('material'); + const wind = await sendFrom('wind'); + + expect(wind).toEqual(material); + }, 40_000); +}); diff --git a/packages/apollo-react/src/chat/wind/composer/chat-composer.tsx b/packages/apollo-react/src/chat/wind/composer/chat-composer.tsx new file mode 100644 index 000000000..1f4f9aa94 --- /dev/null +++ b/packages/apollo-react/src/chat/wind/composer/chat-composer.tsx @@ -0,0 +1,212 @@ +import { + ChatComposer, + ChatComposerAttachButton, + type ChatComposerAttachmentItem, + ChatComposerAttachments, + ChatComposerError, + ChatComposerFooter, + ChatComposerInputGroup, + ChatComposerSubmit, + ChatComposerTextarea, + ChatComposerToolbar, + Markdown, + Tooltip, + TooltipContent, + TooltipPortal, + TooltipTrigger, +} from '@uipath/apollo-wind'; +import React from 'react'; + +import { + getAcceptedFileTypes, + useAttachmentIntake, +} from '../../headless/hooks/use-attachment-intake'; +import { useAttachmentLimits } from '../../headless/hooks/use-attachment-limits'; +import { useChatLabels } from '../../headless/hooks/use-chat-labels'; +import { useComposerSend } from '../../headless/hooks/use-composer-send'; +import { useAttachments } from '../../headless/providers/attachments-provider'; +import { useChatService } from '../../headless/providers/chat-service.provider'; +import { useChatState } from '../../headless/providers/chat-state-provider'; +import { useError } from '../../headless/providers/error-provider'; +import { useLoading } from '../../headless/providers/loading-provider'; +import { + AutopilotChatEvent, + AutopilotChatInternalEvent, + type AutopilotChatPrompt, +} from '../../service'; +import { AttachmentTypeIcon, titleTruncatedChip } from '../messages/message-attachments'; +import { useMarkdownStrings } from '../renderers/markdown-renderer'; +import { useComposerStrings } from './composer-strings'; + +const promptText = (prompt: AutopilotChatPrompt | string | undefined) => + typeof prompt === 'string' ? prompt : (prompt?.content ?? ''); + +/** Error and warning text is markdown, as Material renders it. */ +function ComposerErrorText({ message }: { message: string }) { + const strings = useMarkdownStrings(); + return ( + + {message} + + ); +} + +/** + * The Wind prompt input on apollo-wind's `ChatComposer`, wired as Material's input is: the draft + * follows the service prompt, sending goes through the `Request` intercept with the attachments, + * the send button stops an in-flight reply (unless `stopResponse` is disabled), and the service + * error shows above the field. + */ +export function WindChatComposer() { + const chatService = useChatService(); + const { disabledFeatures, spacing, allowedAttachments } = useChatState(); + const { inputPlaceholder, footerDisclaimer } = useChatLabels(); + const { skeletonLoader } = useLoading(); + const { error, clearError } = useError(); + const { attachments, attachmentsLoading, removeAttachment, clearAttachments } = useAttachments(); + const { isResponseInFlight, canStopResponse, send, stop } = useComposerSend(); + const takeFiles = useAttachmentIntake(); + const strings = useComposerStrings(inputPlaceholder); + const limits = useAttachmentLimits(allowedAttachments); + const textarea = React.useRef(null); + const [value, setValue] = React.useState(() => promptText(chatService?.getPrompt?.())); + + React.useEffect(() => { + if (!chatService) { + return; + } + const unsubscribes = [ + chatService.on(AutopilotChatEvent.SetPrompt, (prompt: AutopilotChatPrompt | string) => + setValue(promptText(prompt)) + ), + chatService.__internalService__.on( + AutopilotChatInternalEvent.SetInputFocused, + (focused: boolean) => { + if (focused) textarea.current?.focus(); + } + ), + ]; + return () => { + for (const unsubscribe of unsubscribes) unsubscribe(); + }; + }, [chatService]); + + // As in Material: an empty or whitespace-only draft clears the service prompt. + const changeValue = (next: string) => { + setValue(next); + chatService.setPrompt(next.trim().length === 0 ? '' : next); + }; + + const items = React.useMemo( + () => + attachments.map((attachment, index) => ({ + id: `${index}-${attachment.name}-${attachment.size}`, + name: attachment.name, + icon: , + state: attachmentsLoading.some( + (loading) => + loading.loading && loading.name === attachment.name && loading.size === attachment.size + ) + ? 'uploading' + : 'done', + })), + [attachments, attachmentsLoading] + ); + + const accept = React.useMemo( + () => getAcceptedFileTypes(allowedAttachments.types), + [allowedAttachments.types] + ); + const limitsId = React.useId(); + + return ( + { + send(content, attachments); + changeValue(''); + clearAttachments(); + // A click on the send button moved focus there; the next message starts in the field. + textarea.current?.focus(); + }} + status={isResponseInFlight ? 'streaming' : 'idle'} + onStop={canStopResponse ? stop : undefined} + submitDisabled={skeletonLoader} + density={spacing.compactMode ? 'compact' : 'comfortable'} + attachments={items} + onRemoveAttachment={(item) => { + const index = items.findIndex(({ id }) => id === item.id); + if (index >= 0) removeAttachment(item.name, index); + }} + strings={strings} + > + {error && ( + { + clearError(); + // The dismiss button goes away with the alert; keep focus in the composer. + textarea.current?.focus(); + }} + className="max-h-75 overflow-y-auto" + > + + + )} + + + + + {!disabledFeatures.attachments && ( + <> + + + { + void takeFiles(accepted, rejected); + }} + /> + + + + {strings.attach} + {limits.map((line) => ( + {line} + ))} + + + + + {limits.join('. ')} + + + )} + {/* The primitive turns stop into a ghost button, and its colour transition reads as a + flicker between send and stop; filled in both states, as Material's is, only the icon + and label change. */} + + + + {!disabledFeatures.footer && ( + // Material's footer size. + {footerDisclaimer} + )} + + ); +} diff --git a/packages/apollo-react/src/chat/wind/composer/chat-dropzone.tsx b/packages/apollo-react/src/chat/wind/composer/chat-dropzone.tsx new file mode 100644 index 000000000..7d80e9931 --- /dev/null +++ b/packages/apollo-react/src/chat/wind/composer/chat-dropzone.tsx @@ -0,0 +1,102 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { DropzoneOverlay, validateFiles } from '@uipath/apollo-wind'; +import type React from 'react'; + +import { + getAcceptedFileTypes, + useAttachmentIntake, +} from '../../headless/hooks/use-attachment-intake'; +import { useAttachments } from '../../headless/providers/attachments-provider'; +import { useChatState } from '../../headless/providers/chat-state-provider'; + +const carriesFiles = (data: DataTransfer | null) => Array.from(data?.types ?? []).includes('Files'); + +function filesFrom(data: DataTransfer | null): File[] { + const files = Array.from(data?.files ?? []); + if (files.length > 0) { + return files; + } + // Some browsers only expose pasted images through `items`. + return Array.from(data?.items ?? []) + .filter((item) => item.kind === 'file') + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null); +} + +/** + * The whole panel as a drop and paste target for attachments, checked against + * `allowedAttachments` and taken in as Material's dropzone takes them. While it cannot take files + * (attachments disabled, no composer, chat closing) a file dropped on the panel is still caught, + * so the browser does not open it in the tab. + */ +export function WindChatDropzone({ + disabled, + children, +}: { + disabled: boolean; + children: React.ReactNode; +}) { + const { _ } = useLingui(); + const { disabledFeatures, allowedAttachments } = useChatState(); + const { attachments } = useAttachments(); + const takeFiles = useAttachmentIntake(); + const inactive = disabled || !!disabledFeatures.attachments; + const accept = getAcceptedFileTypes(allowedAttachments.types); + // `maxCount: 0` means no limit, as in Material; apollo-wind would read it as "none allowed". + // The overlay also counts each drop on its own, so pass what is left of the total. + const maxCount = allowedAttachments.maxCount || undefined; + const remaining = maxCount === undefined ? undefined : Math.max(0, maxCount - attachments.length); + + const swallowDrop = (event: React.DragEvent) => { + if (inactive && carriesFiles(event.dataTransfer)) { + event.preventDefault(); + } + }; + + // Handled here rather than by the overlay, which would block the paste before checking it and + // so lose the text pasted along with a file it then refuses. + const paste = (event: React.ClipboardEvent) => { + if (inactive) { + return; + } + const files = filesFrom(event.clipboardData); + if (files.length === 0) { + return; + } + const { accepted, rejected } = validateFiles(files, { + accept, + maxSize: allowedAttachments.maxSize, + maxFiles: remaining, + }); + if (accepted.length > 0) { + // Keeps the browser from also pasting the file's name as text. + event.preventDefault(); + } + void takeFiles(accepted, rejected); + }; + + return ( + { + void takeFiles(accepted, rejected); + }} + strings={{ + dropHint: _( + msg({ id: 'autopilot-chat.dropzone.overlay-title', message: `Drop files here` }) + ), + }} + > + {children} + + ); +} diff --git a/packages/apollo-react/src/chat/wind/composer/composer-strings.ts b/packages/apollo-react/src/chat/wind/composer/composer-strings.ts new file mode 100644 index 000000000..fc6d6a8de --- /dev/null +++ b/packages/apollo-react/src/chat/wind/composer/composer-strings.ts @@ -0,0 +1,29 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import type { ChatComposerStrings } from '@uipath/apollo-wind'; +import React from 'react'; + +/** The composer's strings, with Material's ids where Material has the same text. */ +export function useComposerStrings(placeholder: string): Partial { + const { _ } = useLingui(); + + return React.useMemo( + () => ({ + label: _(msg({ id: 'autopilot-chat.input.label', message: `Message` })), + placeholder, + send: _(msg({ id: 'autopilot-chat.input.actions.send', message: `Send` })), + stop: _(msg({ id: 'autopilot-chat.input.actions.stop', message: `Stop` })), + attach: _(msg({ id: 'autopilot-chat.input.actions.attach-file', message: `Attach file` })), + // Named `name` to match the catalog's placeholder. + removeAttachment: (name) => + _( + msg({ + id: 'autopilot-chat.common.attachments.remove-file-name', + message: `Remove ${name}`, + }) + ), + dismissError: _(msg({ id: 'autopilot-chat.input.error.dismiss', message: `Dismiss` })), + }), + [_, placeholder] + ); +} diff --git a/packages/apollo-react/src/chat/wind/layout/regions.tsx b/packages/apollo-react/src/chat/wind/layout/regions.tsx index 89e06f08e..ad2ccf9e3 100644 --- a/packages/apollo-react/src/chat/wind/layout/regions.tsx +++ b/packages/apollo-react/src/chat/wind/layout/regions.tsx @@ -1,34 +1,13 @@ -import { useChatLabels } from '../../headless/hooks/use-chat-labels'; -import { useChatState } from '../../headless/providers/chat-state-provider'; +import { WindChatComposer } from '../composer/chat-composer'; import { PlaceholderRegion } from './placeholder-region'; // Each region is replaced by its real implementation as the Wind renderer is built out. -/** - * Shows the configured placeholder and footer disclaimer, so label overrides are visible before - * the real composer replaces this region. - */ +/** The prompt input, with its error and footer disclaimer. */ export function ComposerRegion() { - const { inputPlaceholder, footerDisclaimer } = useChatLabels(); - const { disabledFeatures } = useChatState(); - return ( -
- - - {inputPlaceholder} - - Composer: not available in the Wind renderer yet. - - {!disabledFeatures.footer && ( -

- {footerDisclaimer} -

- )} +
+
); } diff --git a/packages/apollo-react/src/chat/wind/messages/chat-message.tsx b/packages/apollo-react/src/chat/wind/messages/chat-message.tsx index afdfc6080..6665b82e4 100644 --- a/packages/apollo-react/src/chat/wind/messages/chat-message.tsx +++ b/packages/apollo-react/src/chat/wind/messages/chat-message.tsx @@ -61,11 +61,19 @@ function ChatMessageComponent({ data-role={message.role} className="text-foreground" > - + {hasAttachments && message.attachments && ( - // `messageGroupGap` between a message's parts, as in Material. -
- + // `messageGroupGap` between a message's parts, as in Material. `max-w-full`: a user + // message aligns its parts to the end, which sizes them to their content, so without a + // cap the row grows past the panel instead of scrolling. +
+
)} {injected ? ( diff --git a/packages/apollo-react/src/chat/wind/messages/message-attachments.tsx b/packages/apollo-react/src/chat/wind/messages/message-attachments.tsx index 1a250b486..e5c973e6a 100644 --- a/packages/apollo-react/src/chat/wind/messages/message-attachments.tsx +++ b/packages/apollo-react/src/chat/wind/messages/message-attachments.tsx @@ -4,8 +4,10 @@ import { AttachmentGroup, AttachmentMedia, AttachmentTitle, + cn, } from '@uipath/apollo-wind'; import { File, FileText, type LucideIcon, Presentation } from 'lucide-react'; +import type React from 'react'; import { getAttachmentFileType } from '../../headless/utils/file-type'; import { type AutopilotChatFileInfo, AutopilotChatFileType } from '../../service'; @@ -17,28 +19,70 @@ const FILE_TYPE_ICONS: Record = { [AutopilotChatFileType.File]: File, }; -/** The files sent with a message. */ -export function MessageAttachments({ attachments }: { attachments: AutopilotChatFileInfo[] }) { - return ( - - {attachments.map((attachment) => { - const Icon = FILE_TYPE_ICONS[getAttachmentFileType(attachment.name)]; +/** The icon for a file's type, by its name. */ +export function AttachmentTypeIcon({ name }: { name: string }) { + const Icon = FILE_TYPE_ICONS[getAttachmentFileType(name)]; + return ; +} + +// Material's chip size: 30px tall and at most half the message, with the name truncated. Half +// the message (the `@container` around it), not half the row: an end-aligned row sizes itself to +// its chips, so a percentage of it would shrink a lone chip to half its own width. The xs chip's +// 28px media square shrinks to a 20px icon; `!` because the primitive sizes it per size. +export const CHIP_CLASSES = 'min-w-0 max-w-[calc(50cqw-4px)] gap-1.5 rounded-lg'; +export const CHIP_MEDIA_CLASSES = 'w-5! rounded-sm bg-transparent'; - return ( - - - - - - {attachment.name} - - - ); - })} +/** + * Shows a chip's full name as its tooltip only while the name is cut off. Delegated, so it also + * covers chips a primitive renders. + */ +export function titleTruncatedChip(event: React.PointerEvent) { + const chip = (event.target as HTMLElement).closest('[data-slot="attachment"]'); + const title = chip?.querySelector('[data-slot="attachment-title"]'); + if (!chip || !title) { + return; + } + if (title.scrollWidth > title.clientWidth) { + chip.title = title.textContent ?? ''; + } else { + chip.removeAttribute('title'); + } +} + +/** + * The files sent with a message. With `align="end"` (a user message) the row sits at the + * message's trailing edge while the chips fit. + */ +export function MessageAttachments({ + attachments, + align = 'start', +}: { + attachments: AutopilotChatFileInfo[]; + align?: 'start' | 'end'; +}) { + return ( + + {attachments.map((attachment) => ( + + + + + + {attachment.name} + + + ))} ); } diff --git a/packages/apollo-react/src/chat/wind/messages/message-list.test.tsx b/packages/apollo-react/src/chat/wind/messages/message-list.test.tsx index 2696d07fe..9ce59e16f 100644 --- a/packages/apollo-react/src/chat/wind/messages/message-list.test.tsx +++ b/packages/apollo-react/src/chat/wind/messages/message-list.test.tsx @@ -125,6 +125,49 @@ describe('Wind message list', () => { expect(withText?.querySelector('[data-slot="bubble"]')).toHaveTextContent('With text'); }); + it('caps chips at half the message, not half their row', async () => { + const service = createService(); + render(); + + await act(async () => { + service.sendRequest({ + content: 'One file', + attachments: [{ name: 'a-long-file-name.docx', type: 'application/msword', size: 1 }], + }); + }); + + const files = document.querySelector( + '[data-slot="wind-chat-message-attachments"]' + ) as HTMLElement; + const chip = files.querySelector('[data-slot="attachment"]') as HTMLElement; + // `cqw` resolves against the message content, which is full width whatever the row's size, + // so a lone chip in an end-aligned row keeps its width; two chips and the 8px gap fill it. + expect(chip).toHaveClass('max-w-[calc(50cqw-4px)]'); + expect(files).toHaveClass('gap-2'); + expect(files.closest('[data-slot="message-content"]')).toHaveClass('@container'); + }); + + it("end-aligns a user message's files, not a reply's", async () => { + const service = createService(); + render(); + const files = [ + { name: 'one.docx', type: 'application/msword', size: 1 }, + { name: 'two.docx', type: 'application/msword', size: 2 }, + ]; + + await act(async () => { + service.sendRequest({ content: 'Mine', attachments: files }); + }); + act(() => service.sendResponse(reply({ content: 'Theirs', attachments: files }))); + + const [mine, theirs] = Array.from( + document.querySelectorAll('[data-slot="wind-chat-message-attachments"]') + ); + // `safe`: end-justified while the chips fit, from the start once they overflow. + expect(mine).toHaveClass('justify-end-safe'); + expect(theirs).not.toHaveClass('justify-end-safe'); + }); + it('follows a streamed reply in the built-in text renderer', () => { const service = createService(); render(); diff --git a/packages/apollo-react/src/chat/wind/renderers/markdown-renderer.tsx b/packages/apollo-react/src/chat/wind/renderers/markdown-renderer.tsx index 2b09ac29a..bf0b4cd1b 100644 --- a/packages/apollo-react/src/chat/wind/renderers/markdown-renderer.tsx +++ b/packages/apollo-react/src/chat/wind/renderers/markdown-renderer.tsx @@ -22,7 +22,8 @@ const COMPONENTS = { 'resource-chip': MarkdownResourceChip, } as unknown as MarkdownProps['components']; -function useMarkdownStrings(): MarkdownProps['strings'] { +/** The Markdown primitive's strings from the chat catalog. */ +export function useMarkdownStrings(): MarkdownProps['strings'] { const { _ } = useLingui(); return React.useMemo( diff --git a/packages/apollo-react/src/material/components/ap-chat/components/dropzone/dropzone.test.ts b/packages/apollo-react/src/material/components/ap-chat/components/dropzone/dropzone.test.ts new file mode 100644 index 000000000..6536611de --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-chat/components/dropzone/dropzone.test.ts @@ -0,0 +1,14 @@ +import { ErrorCode } from 'react-dropzone'; +import { describe, expect, it } from 'vitest'; + +import { rejectionReason } from './dropzone'; + +describe('rejectionReason', () => { + it('maps react-dropzone error codes onto the shared intake reasons', () => { + expect(rejectionReason(ErrorCode.FileTooLarge)).toBe('size'); + expect(rejectionReason(ErrorCode.FileInvalidType)).toBe('type'); + expect(rejectionReason(ErrorCode.TooManyFiles)).toBe('count'); + expect(rejectionReason(ErrorCode.FileTooSmall)).toBe('other'); + expect(rejectionReason(undefined)).toBe('other'); + }); +}); diff --git a/packages/apollo-react/src/material/components/ap-chat/components/dropzone/dropzone.tsx b/packages/apollo-react/src/material/components/ap-chat/components/dropzone/dropzone.tsx index d9c5ebca7..8dfae27d4 100644 --- a/packages/apollo-react/src/material/components/ap-chat/components/dropzone/dropzone.tsx +++ b/packages/apollo-react/src/material/components/ap-chat/components/dropzone/dropzone.tsx @@ -2,11 +2,11 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { styled } from '@mui/material/styles'; import token, { FontVariantToken } from '@uipath/apollo-core'; -import { useAttachments } from '@uipath/apollo-react/chat/headless/providers/attachments-provider'; +import { + type ChatAttachmentRejectionReason, + useAttachmentIntake, +} from '@uipath/apollo-react/chat/headless/hooks/use-attachment-intake'; import { useChatState } from '@uipath/apollo-react/chat/headless/providers/chat-state-provider'; -import { useError } from '@uipath/apollo-react/chat/headless/providers/error-provider'; -import { parseFiles } from '@uipath/apollo-react/chat/headless/utils/file-reader'; -import type { AutopilotChatFileInfo } from '@uipath/apollo-react/chat/service'; import { ApTypography } from '@uipath/apollo-react/material/components/ap-typography'; import React from 'react'; import { type DropzoneOptions, ErrorCode, type FileRejection, useDropzone } from 'react-dropzone'; @@ -46,129 +46,38 @@ const DropzoneOverlay = styled('div')(() => ({ boxSizing: 'border-box', })); +/** react-dropzone's error codes as the shared intake's rejection reasons. */ +export function rejectionReason(code: string | undefined): ChatAttachmentRejectionReason { + switch (code) { + case ErrorCode.FileTooLarge: + return 'size'; + case ErrorCode.FileInvalidType: + return 'type'; + case ErrorCode.TooManyFiles: + return 'count'; + default: + return 'other'; + } +} + function AutopilotChatDropzoneComponent({ children, ...dropzoneOptions }: AutopilotChatDropzoneProps) { const { _ } = useLingui(); - const { addAttachments, attachments } = useAttachments(); - const { setError } = useError(); const { disabledFeatures, allowedAttachments } = useChatState(); - - const attachmentsCountRef = React.useRef(attachments.length); - attachmentsCountRef.current = attachments.length; - - const handleRejections = React.useCallback( - (parsedFiles: AutopilotChatFileInfo[], fileRejections: FileRejection[]) => { - if ( - !allowedAttachments.multiple && - (parsedFiles.length > 1 || attachmentsCountRef.current > 0 || fileRejections.length > 1) - ) { - setError( - _( - msg({ - id: 'autopilot-chat.error.multiple-files', - message: `Only one file can be attached at a time`, - }) - ) - ); - - return true; - } - - if (fileRejections.length > 0) { - const tooManyFilesRejection = fileRejections.find( - (rejection) => rejection.errors[0]?.code === ErrorCode.TooManyFiles - ); - - if (tooManyFilesRejection) { - const maxCount = allowedAttachments.maxCount; - setError( - _( - msg({ - id: 'autopilot-chat.error.too-many-files', - message: `Maximum ${maxCount} files allowed`, - }) - ) - ); - - return true; - } - - const errorMessages: string[] = []; - - for (const { errors, file } of fileRejections) { - const errorCode = errors[0]?.code; - - if (errorCode === ErrorCode.FileTooLarge) { - const fileSizeMB = Math.round(file.size / 1024 / 1024); - const fileName = file.name; - const maxSize = allowedAttachments.maxSize / 1024 / 1024; - - errorMessages.push( - _( - msg({ - id: 'autopilot-chat.error.file-too-large', - message: `${fileName} (${fileSizeMB}MB) exceeds maximum size of ${maxSize}MB`, - }) - ) - ); - } - - if (errorCode === ErrorCode.FileInvalidType) { - const fileName = file.name; - errorMessages.push( - _( - msg({ - id: 'autopilot-chat.error.file-invalid-type', - message: `${fileName} has an invalid file type`, - }) - ) - ); - } - } - - if (errorMessages.length > 0) { - setError(errorMessages.join('\n')); - } - } - - return false; - }, - [ - _, - setError, - allowedAttachments.maxCount, - allowedAttachments.maxSize, - allowedAttachments.multiple, - ] - ); + const takeFiles = useAttachmentIntake(); const handleDrop = React.useCallback( - async (files: File[], fileRejections: FileRejection[]) => { - try { - const parsedFiles = await parseFiles(files); - const shouldReturn = handleRejections(parsedFiles, fileRejections); - - if (shouldReturn) { - return; - } - - if (fileRejections.length === 0) { - setError(undefined); - } - - if (allowedAttachments.multiple) { - addAttachments(parsedFiles); - } else if (parsedFiles.length === 1) { - addAttachments([parsedFiles[0]!]); - } - } catch (error) { - setError(error as string); - return; - } - }, - [addAttachments, setError, allowedAttachments.multiple, handleRejections] + (files: File[], fileRejections: FileRejection[]) => + takeFiles( + files, + fileRejections.map(({ file, errors }) => ({ + file, + reason: rejectionReason(errors[0]?.code), + })) + ), + [takeFiles] ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ diff --git a/packages/apollo-react/src/material/components/ap-chat/components/input/chat-input-actions.tsx b/packages/apollo-react/src/material/components/ap-chat/components/input/chat-input-actions.tsx index 50c48bf67..e2faa2611 100644 --- a/packages/apollo-react/src/material/components/ap-chat/components/input/chat-input-actions.tsx +++ b/packages/apollo-react/src/material/components/ap-chat/components/input/chat-input-actions.tsx @@ -2,12 +2,15 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { styled } from '@mui/material'; import token, { FontVariantToken } from '@uipath/apollo-core'; +import { + checkAttachmentFiles, + useAttachmentIntake, +} from '@uipath/apollo-react/chat/headless/hooks/use-attachment-intake'; +import { useAttachmentLimits } from '@uipath/apollo-react/chat/headless/hooks/use-attachment-limits'; import { useAttachments } from '@uipath/apollo-react/chat/headless/providers/attachments-provider'; import { useChatService } from '@uipath/apollo-react/chat/headless/providers/chat-service.provider'; import { useChatState } from '@uipath/apollo-react/chat/headless/providers/chat-state-provider'; -import { useError } from '@uipath/apollo-react/chat/headless/providers/error-provider'; import { usePicker } from '@uipath/apollo-react/chat/headless/providers/picker-provider'; -import { parseFiles } from '@uipath/apollo-react/chat/headless/utils/file-reader'; import { AutopilotChatEvent } from '@uipath/apollo-react/chat/service'; import { ApTypography } from '@uipath/apollo-react/material/components/ap-typography'; import React from 'react'; @@ -77,8 +80,8 @@ function AutopilotChatInputActionsComponent({ }: AutopilotChatInputActionsProps) { const { _ } = useLingui(); const chatService = useChatService(); - const { addAttachments } = useAttachments(); - const { setError } = useError(); + const { attachments } = useAttachments(); + const takeFiles = useAttachmentIntake(); const { disabledFeatures, allowedAttachments } = useChatState(); const { models, agentModes } = usePicker(); @@ -113,83 +116,25 @@ function AutopilotChatInputActionsComponent({ } }; + // The same checks and messages as the drop target, so both validate types, size and count. const handleAttachment = React.useCallback( - async (event: React.ChangeEvent) => { - try { - const parsedFiles = await parseFiles(Array.from(event.target.files ?? [])); - const oversizedFiles = parsedFiles.filter((file) => file.size > allowedAttachments.maxSize); - - if (oversizedFiles.length > 0) { - const errorMessages = oversizedFiles.map((file) => { - const fileSizeMB = Math.round(file.size / 1024 / 1024); - const fileName = file.name; - const fileSize = fileSizeMB; - const maxSize = allowedAttachments.maxSize / 1024 / 1024; - - return _( - msg({ - id: 'autopilot-chat.input.actions.error.file-too-large', - message: `File "${fileName}" (${fileSize}MB) exceeds the maximum size of ${maxSize}MB`, - }) - ); - }); - - setError(errorMessages.join('\n')); - } else { - setError(undefined); - } - - addAttachments(parsedFiles.filter((file) => file.size <= allowedAttachments.maxSize)); - } catch (err) { - setError(err as string); - } + (event: React.ChangeEvent) => { + const { accepted, rejected } = checkAttachmentFiles( + Array.from(event.target.files ?? []), + allowedAttachments, + attachments.length + ); + void takeFiles(accepted, rejected); }, - [addAttachments, setError, allowedAttachments.maxSize, _] + [takeFiles, allowedAttachments, attachments.length] ); - const acceptedExtensions = React.useMemo(() => { return Object.values(allowedAttachments.types).flat().join(','); }, [allowedAttachments]); - // Build full description text for screen readers - const attachmentDescription = React.useMemo(() => { - const parts: string[] = []; - const maxCount = allowedAttachments.maxCount; - const maxSize = allowedAttachments.maxSize / 1024 / 1024; - const fileTypes = acceptedExtensions.split(',').join(', '); - - if ( - allowedAttachments.maxCount && - allowedAttachments.maxCount > 1 && - allowedAttachments.multiple - ) { - parts.push( - _( - msg({ - id: 'autopilot-chat.input.actions.attachments.max-count', - message: `Maximum ${maxCount} files`, - }) - ) - ); - } - - parts.push( - _( - msg({ - id: 'autopilot-chat.input.actions.attachments.max-size', - message: `Maximum ${maxSize}MB per file`, - }) - ), - _( - msg({ - id: 'autopilot-chat.input.actions.attachments.allowed-types', - message: `Allowed types: ${fileTypes}`, - }) - ) - ); - - return parts.join('. '); - }, [allowedAttachments, acceptedExtensions, _]); + // The limits, shared with the Wind renderer: as tooltip lines and as screen-reader text. + const attachmentLimits = useAttachmentLimits(allowedAttachments); + const attachmentDescription = attachmentLimits.join('. '); // Show voice interaction button when audioStreaming is enabled and either voice // is already active, or the input is genuinely empty (no text, no attachments). @@ -246,59 +191,15 @@ function AutopilotChatInputActionsComponent({ )} - {allowedAttachments.maxCount && - allowedAttachments.maxCount > 1 && - allowedAttachments.multiple && - (() => { - const maxCount = allowedAttachments.maxCount; - return ( - - {_( - msg({ - id: 'autopilot-chat.input.actions.attachments.max-count', - message: `Maximum ${maxCount} files`, - }) - )} - - ); - })()} - - {(() => { - const maxSize = allowedAttachments.maxSize / 1024 / 1024; - return ( - - {_( - msg({ - id: 'autopilot-chat.input.actions.attachments.max-size', - message: `Maximum ${maxSize}MB per file`, - }) - )} - - ); - })()} - - {(() => { - const fileTypes = acceptedExtensions.split(',').join(', '); - return ( - - {_( - msg({ - id: 'autopilot-chat.input.actions.attachments.allowed-types', - message: `Allowed types: ${fileTypes}`, - }) - )} - - ); - })()} + {attachmentLimits.map((line) => ( + + {line} + + ))} } /> diff --git a/packages/apollo-react/src/material/components/ap-chat/components/input/chat-input.test.tsx b/packages/apollo-react/src/material/components/ap-chat/components/input/chat-input.test.tsx index 950eeb253..e0a0a8d96 100644 --- a/packages/apollo-react/src/material/components/ap-chat/components/input/chat-input.test.tsx +++ b/packages/apollo-react/src/material/components/ap-chat/components/input/chat-input.test.tsx @@ -138,3 +138,31 @@ describe(' stop response', () => { expect(submitButton()).toBeDisabled(); }); }); + +describe(' attach button', () => { + it('checks picked files as the drop target does: type, size and count', async () => { + const user = userEvent.setup({ applyAccept: false }); + const { chatService } = renderInput(); + act(() => { + chatService.stopResponse(); + chatService.setAllowedAttachments({ + multiple: true, + types: { 'text/plain': ['.txt'] }, + maxSize: 5, + maxCount: 3, + }); + }); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const errors: Array = []; + chatService.on(AutopilotChatEvent.Error, (error: { message?: string } | undefined) => + errors.push(error?.message) + ); + + await user.upload(input, [ + new File(['x'.repeat(20)], 'big.txt', { type: 'text/plain' }), + new File(['x'], 'tool.exe', { type: 'application/octet-stream' }), + ]); + + await vi.waitFor(() => expect(errors.at(-1)).toMatch(/big\.txt[\s\S]*tool\.exe/)); + }); +}); diff --git a/packages/apollo-react/src/material/components/ap-chat/components/input/chat-input.tsx b/packages/apollo-react/src/material/components/ap-chat/components/input/chat-input.tsx index 149db22ae..68bff39b5 100644 --- a/packages/apollo-react/src/material/components/ap-chat/components/input/chat-input.tsx +++ b/packages/apollo-react/src/material/components/ap-chat/components/input/chat-input.tsx @@ -1,12 +1,12 @@ import { Box, styled } from '@mui/material'; import token, { type FontVariantToken } from '@uipath/apollo-core'; import { useChatLabels } from '@uipath/apollo-react/chat/headless/hooks/use-chat-labels'; +import { useComposerSend } from '@uipath/apollo-react/chat/headless/hooks/use-composer-send'; import { useAttachments } from '@uipath/apollo-react/chat/headless/providers/attachments-provider'; import { useChatService } from '@uipath/apollo-react/chat/headless/providers/chat-service.provider'; import { useChatState } from '@uipath/apollo-react/chat/headless/providers/chat-state-provider'; import { useLoading } from '@uipath/apollo-react/chat/headless/providers/loading-provider'; import { useResourceData } from '@uipath/apollo-react/chat/headless/providers/resource-data-provider'; -import { useStreaming } from '@uipath/apollo-react/chat/headless/providers/streaming-provider'; import { parseFiles } from '@uipath/apollo-react/chat/headless/utils/file-reader'; import { AutopilotChatEvent, @@ -94,8 +94,7 @@ function AutopilotChatInputComponent() { const editorRef = React.useRef(null); const inputContainerRef = React.useRef(null); - const { waitingResponse, skeletonLoader } = useLoading(); - const { streaming } = useStreaming(); + const { skeletonLoader } = useLoading(); const { attachments, clearAttachments, addAttachments, attachmentsLoading } = useAttachments(); const editorLineHeight = React.useMemo(() => { @@ -143,43 +142,24 @@ function AutopilotChatInputComponent() { [chatService] ); - const isResponseInFlight = waitingResponse || streaming; - // Stop is the submit button flipped, not a separate control. When the stopResponse // feature is disabled the button stays a disabled send button instead of offering stop. - const canStopResponse = isResponseInFlight && !disabledFeatures?.stopResponse; + const { isResponseInFlight, canStopResponse, send, stop } = useComposerSend(); const handleSubmit = React.useCallback(() => { + // Stopping does not also end dictation; only a send does. if (isResponseInFlight) { - if (canStopResponse) { - chatService.stopResponse(); - } - + stop(); return; } - // Stop dictation before sending so the growing transcript can't re-populate the - // input we're about to clear. Using `publishSpeechToTextToggle` (not - // `setSpeechToTextState`) is deliberate — consumers listen for - // SpeechToTextToggle to tear down the recognizer. Intentionally gated behind the - // stop-response early-return above so hitting "Stop response" doesn't also kill - // active dictation. - if (chatService.isSpeechToTextActive) { - chatService.publishSpeechToTextToggle(); - } - - const serializedContent = editorRef.current?.getSerializedContent() ?? message; - - chatService.sendRequest({ - content: serializedContent, - attachments, - }); + send(editorRef.current?.getSerializedContent() ?? message, attachments); // clear input editorRef.current?.clear(); setMessage(''); clearAttachments(); - }, [message, attachments, clearAttachments, chatService, isResponseInFlight, canStopResponse]); + }, [message, attachments, clearAttachments, isResponseInFlight, send, stop]); const handleKeyDown = React.useCallback( (event: KeyboardEvent): boolean => {