Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/example/src/pages/CustomSlotsDemoPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,22 @@ function OrbitComposer({
onAbort,
onOpenFilePicker,
onRemoveAttachment,
onAddFiles,
}: ChatComposerSlotProps) {
// ToolApproval is rendered independently by use-ai. Hide the composer while
// an approval is pending to match the built-in interaction pattern.
if (pendingApprovals.length > 0) return null;

// Files pasted into a replacement composer go through the chat's own file
// handling, so they end up on the message the panel sends.
const handlePaste = (event: React.ClipboardEvent) => {
if (!fileUploadEnabled || event.clipboardData.files.length === 0) return;
if (!event.clipboardData.types.includes('text/plain')) {
event.preventDefault();
}
onAddFiles(event.clipboardData.files);
};

return (
<footer className="orbit-composer-wrap" data-testid="orbit-composer">
{attachments.length > 0 && (
Expand All @@ -260,6 +271,7 @@ function OrbitComposer({
aria-label="Message Orbit"
value={input}
onChange={(event) => onInputChange(event.target.value)}
onPaste={handlePaste}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
Expand Down
7 changes: 5 additions & 2 deletions apps/example/src/pages/MultimodalPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export default function MultimodalPage() {
<h2 style={docStyles.subtitle}>About</h2>
<p style={docStyles.text}>
Enable file uploads in chat with the <code style={docStyles.code}>fileUploadConfig</code> prop.
Files get in through the attach button, a drop anywhere on the chat panel, or a
paste into the input, all subject to the same limits.
By default, files are base64-encoded and embedded in the message using{' '}
<code style={docStyles.code}>EmbedFileUploadBackend</code>. Configure{' '}
<code style={docStyles.code}>maxFileSize</code>,{' '}
Expand Down Expand Up @@ -89,8 +91,9 @@ export default function MultimodalPage() {
<div style={docStyles.demoCard}>
<h2 style={docStyles.subtitle}>Interactive Demo</h2>
<p style={docStyles.text}>
This chat has file uploads enabled. Click the paperclip icon to attach an image
or PDF, then send it to the AI.
This chat has file uploads enabled. Attach an image or PDF with the paperclip
icon, drop one onto the chat, or copy a screenshot and paste it into the input,
then send it to the AI.
</p>
<UseAIProvider
serverUrl="ws://localhost:8081"
Expand Down
105 changes: 105 additions & 0 deletions apps/example/test/file-paste.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { test, expect, type Page } from '@playwright/test';

/**
* Pasting files into the built-in composer.
*
* A real browser is the only place the paste path can be exercised end to end:
* the clipboard payload, `preventDefault` on the paste, and the image preview
* (which goes through FileReader) all come from the browser.
*/
test.describe('Paste to attach', () => {
test.setTimeout(60000);

/**
* Pastes a clipboard entry into the composer and reports whether the
* composer took the paste over.
*/
const pasteIntoComposer = (
page: Page,
clipboard: { fileName?: string; mimeType?: string; text?: string }
) =>
page.evaluate(({ fileName, mimeType, text }) => {
const transfer = new DataTransfer();

if (mimeType) {
// A 1x1 transparent PNG, the smallest stand-in for a screenshot.
const png = Uint8Array.from(atob(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
), (char) => char.charCodeAt(0));
transfer.items.add(new File([png], fileName ?? '', { type: mimeType }));
}
if (text !== undefined) {
transfer.setData('text/plain', text);
}

const input = document.querySelector('[data-testid="chat-input"]');
if (!input) throw new Error('composer input not found');

const event = new ClipboardEvent('paste', {
clipboardData: transfer,
bubbles: true,
cancelable: true,
});
input.dispatchEvent(event);

return event.defaultPrevented;
}, clipboard);

test.beforeEach(async ({ page }) => {
// The example server needs a key to start, so the page cannot connect without one.
if (!process.env.ANTHROPIC_API_KEY) {
test.skip();
}

await page.goto('/');
await page.evaluate(() => localStorage.clear());
await page.reload();

await page.getByRole('button', { name: 'File Transformers' }).click();
await expect(page.getByRole('heading', { level: 2, name: 'File Transformers' })).toBeVisible();
await expect(page.getByTestId('chat-input')).toBeVisible({ timeout: 10000 });
});

test('attaches a pasted screenshot', async ({ page }) => {
const prevented = await pasteIntoComposer(page, { fileName: 'screenshot.png', mimeType: 'image/png' });

// Nothing to insert, so the composer takes the paste over.
expect(prevented).toBe(true);
await expect(page.getByTestId('file-chip')).toContainText('screenshot.png', { timeout: 5000 });

// The paste goes through the same pipeline as the picker, transformer included.
await expect(page.getByTestId('file-chip-processing')).toBeVisible({ timeout: 2000 });
await expect(page.getByTestId('file-chip-processing')).toBeHidden({ timeout: 10000 });
});

test('keeps the text insertion when the clipboard carries text too', async ({ page }) => {
const prevented = await pasteIntoComposer(page, {
fileName: 'cell.png',
mimeType: 'image/png',
text: 'Revenue: 1200',
});

expect(prevented).toBe(false);
await expect(page.getByTestId('file-chip')).toBeVisible({ timeout: 5000 });
});

test('labels a screenshot the browser gave no name', async ({ page }) => {
await pasteIntoComposer(page, { mimeType: 'image/png' });

await expect(page.getByTestId('file-chip')).toContainText('pasted-image.png', { timeout: 5000 });
});

test('shows why an unaccepted file was rejected', async ({ page }) => {
await pasteIntoComposer(page, { fileName: 'archive.zip', mimeType: 'application/zip' });

await expect(page.getByTestId('file-error')).toContainText('application/zip', { timeout: 5000 });
await expect(page.getByTestId('file-chip')).toHaveCount(0);
});

test('does nothing when the clipboard carries only text', async ({ page }) => {
const prevented = await pasteIntoComposer(page, { text: 'just text' });

expect(prevented).toBe(false);
await expect(page.getByTestId('file-chip')).toHaveCount(0);
});
});
107 changes: 107 additions & 0 deletions packages/client/src/components/UseAIChatPanel.paste.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React from 'react';
import { describe, test, expect, mock } from 'bun:test';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { UseAIChatPanel } from './UseAIChatPanel';
import type { FileUploadConfig } from '../fileUpload/types';

// Images take the FileReader path for preview generation, which the test
// environment does not provide, so these use PDFs. Pasting an image is covered
// by the e2e test in apps/example.
const config: FileUploadConfig = {
acceptedTypes: ['image/*', 'application/pdf'],
};

function renderPanel({ fileUpload = true }: { fileUpload?: boolean } = {}) {
return render(
<UseAIChatPanel
onSendMessage={mock(() => {})}
messages={[]}
loading={false}
connected
fileUploadConfig={fileUpload ? config : undefined}
/>
);
}

/** A clipboard carrying `files`, and whatever `types` the source advertised. */
function clipboard(types: string[], files: File[]) {
return {
clipboardData: {
files,
types,
getData: () => '',
},
};
}

const pdf = () => new File(['%PDF-1.4'], 'report.pdf', { type: 'application/pdf' });

describe('pasting files into the built-in composer', () => {
test('attaches the pasted file', async () => {
const { getByTestId } = renderPanel();

fireEvent.paste(getByTestId('chat-input'), clipboard(['Files'], [pdf()]));

await waitFor(() => expect(getByTestId('file-chip')).toHaveTextContent('report.pdf'));
});

test('takes the paste over when the clipboard carries no text', async () => {
const { getByTestId } = renderPanel();

const notPrevented = fireEvent.paste(getByTestId('chat-input'), clipboard(['Files'], [pdf()]));

expect(notPrevented).toBe(false);
await waitFor(() => expect(getByTestId('file-chip')).toBeInTheDocument());
});

test('leaves the text insertion alone when the clipboard also carries text', async () => {
const { getByTestId } = renderPanel();

const notPrevented = fireEvent.paste(
getByTestId('chat-input'),
clipboard(['text/plain', 'text/html', 'Files'], [pdf()])
);

// The browser still inserts the text; the file is attached alongside it.
expect(notPrevented).toBe(true);
await waitFor(() => expect(getByTestId('file-chip')).toBeInTheDocument());
});

test('ignores a paste that carries no file', () => {
const { getByTestId, queryByTestId } = renderPanel();

const notPrevented = fireEvent.paste(getByTestId('chat-input'), clipboard(['text/plain'], []));

expect(notPrevented).toBe(true);
expect(queryByTestId('file-chip')).toBeNull();
});

test('reports why a file was rejected instead of doing nothing', async () => {
const { getByTestId, queryByTestId } = renderPanel();

const zip = new File(['zip'], 'archive.zip', { type: 'application/zip' });
fireEvent.paste(getByTestId('chat-input'), clipboard(['Files'], [zip]));

await waitFor(() => expect(getByTestId('file-error')).toHaveTextContent('application/zip'));
expect(queryByTestId('file-chip')).toBeNull();
});

test('labels a file the browser gave no name', async () => {
const { getByTestId } = renderPanel();

const unnamed = new File(['%PDF-1.4'], '', { type: 'application/pdf' });
fireEvent.paste(getByTestId('chat-input'), clipboard(['Files'], [unnamed]));

await waitFor(() => expect(getByTestId('file-chip')).toHaveTextContent('pasted-file.pdf'));
});

test('does nothing when file upload is not configured', () => {
const { getByTestId, queryByTestId } = renderPanel({ fileUpload: false });

const notPrevented = fireEvent.paste(getByTestId('chat-input'), clipboard(['Files'], [pdf()]));

expect(notPrevented).toBe(true);
expect(queryByTestId('file-chip')).toBeNull();
});
});

2 changes: 2 additions & 0 deletions packages/client/src/components/UseAIChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export function UseAIChatPanel({
acceptedTypes,
processingState: fileProcessingState,
fileInputRef,
handleFiles,
removeAttachment,
clearAttachments,
openFilePicker,
Expand Down Expand Up @@ -466,6 +467,7 @@ export function UseAIChatPanel({
onAbort,
onOpenFilePicker: openFilePicker,
onRemoveAttachment: removeAttachment,
onAddFiles: handleFiles,
submitMode,
attachmentProcessing: fileProcessingState,
disclaimerVisible: showInputDisclaimer,
Expand Down
41 changes: 40 additions & 1 deletion packages/client/src/components/UseAIChatSlots.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { describe, test, expect, mock } from 'bun:test';
import { fireEvent, render } from '@testing-library/react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { UseAIChatPanel } from './UseAIChatPanel';
import type {
ChatComposerSlotProps,
Expand Down Expand Up @@ -236,4 +236,43 @@ describe('UseAIChat component slots', () => {

expect(onSendMessage).toHaveBeenCalledWith('Hello', undefined);
});

test('sends files a custom Composer took in itself', async () => {
const onSendMessage = mock(() => {});
// A composer taking files in through its own UI: the file never goes
// through the panel's picker or drop zone.
const Composer = ({ canSend, onSend, attachments, onAddFiles }: ChatComposerSlotProps) => (
<div>
<button
data-testid="custom-attach"
onClick={() => onAddFiles([new File(['%PDF-1.4'], 'report.pdf', { type: 'application/pdf' })])}
>
Attach
</button>
<span data-testid="custom-attachments">
{attachments.map((attachment) => attachment.file.name).join(',')}
</span>
<button data-testid="custom-send" disabled={!canSend} onClick={onSend}>Send</button>
</div>
);

const { getByTestId } = renderPanel(
{ Composer },
{
messages: [],
loading: false,
pendingApprovals: [],
onSendMessage,
fileUploadConfig: { acceptedTypes: ['application/pdf'] },
}
);

fireEvent.click(getByTestId('custom-attach'));
await waitFor(() => expect(getByTestId('custom-attachments')).toHaveTextContent('report.pdf'));

fireEvent.click(getByTestId('custom-send'));

const [, attachments] = onSendMessage.mock.calls[0] as unknown as [string, unknown[]];
expect(attachments).toHaveLength(1);
});
});
10 changes: 10 additions & 0 deletions packages/client/src/components/chatSlots/DefaultComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function DefaultComposer({
onAbort,
onOpenFilePicker,
onRemoveAttachment,
onAddFiles,
submitMode,
attachmentProcessing,
disclaimerVisible,
Expand All @@ -47,6 +48,14 @@ export function DefaultComposer({
onInputChange(e.target.value);
};

const handlePaste = (e: React.ClipboardEvent) => {
if (!fileUploadEnabled || e.clipboardData.files.length === 0) return;
if (!e.clipboardData.types.includes('text/plain')) {
e.preventDefault();
}
onAddFiles(e.clipboardData.files);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
// Slash command navigation claims the key first.
if (slashCommands.onKeyDown(e)) return;
Expand Down Expand Up @@ -126,6 +135,7 @@ export function DefaultComposer({
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder}
disabled={!connected || loading || pendingApprovals.length > 0}
rows={1}
Expand Down
13 changes: 13 additions & 0 deletions packages/client/src/components/chatSlots/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,19 @@ export interface ChatComposerSlotProps extends ChatSlotProps {
onAbort?: () => void;
onOpenFilePicker: () => void;
onRemoveAttachment: (attachmentId: string) => void;
/**
* Takes files in through this chat's own file handling, so they end up on
* the message `onSend` sends. Use it for a way in the panel does not already
* provide: a paste, a composer-sized drop area, a screenshot button. Size,
* type and count validation, previews and transformers all run on the way
* in, and a rejection surfaces through `fileError`, so files can be handed
* over unfiltered.
*
* Files are taken in asynchronously, which is too late to decide whether to
* call `preventDefault()` on a paste or drop event. Decide that from the
* event itself.
*/
onAddFiles: (files: FileList | File[]) => void;
/** How the Enter key should behave; see `UseAIChatProps.submitMode`. */
submitMode: SubmitMode;
/** Per-attachment transformation progress (e.g. OCR), keyed by attachment id. */
Expand Down
Loading
Loading