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 => {