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
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { globalStyle, style } from '@vanilla-extract/css';
import { scope, vars } from '@/shared/ui/assistant-theme/AssistantScope.css';

export const composer = style({
display: 'flex',
flexDirection: 'column',
gap: vars.space2
});

export const note = style({
display: 'flex',
alignItems: 'center',
gap: vars.space2,
margin: 0,
color: vars.textSecondary,
fontSize: vars.fontSizeSm
});

export const field = style({
display: 'flex',
flexDirection: 'column',
gap: vars.space1,
padding: vars.space2,
borderRadius: vars.radiusLg,
border: `1px solid ${vars.border}`,
background: vars.bg,
transition: `border-color ${vars.durationFast} ${vars.easeStandard}, box-shadow ${vars.durationFast} ${vars.easeStandard}`,
selectors: {
// The field draws the focus ring for the textarea inside it.
'&:focus-within': { borderColor: vars.focus, boxShadow: `0 0 0 1px ${vars.focus}` },
'&[data-disabled]': { background: vars.fill }
}
});

export const input = style({
padding: `${vars.space1} ${vars.space2}`,
fontSize: vars.fontSize
});

globalStyle(`${scope} ${input}:focus-visible`, { outline: 'none' });
// antd's placeholder colour falls below 4.5:1 on the panel; textTertiary is lifted to meet it.
globalStyle(`${scope} ${input}::placeholder`, { color: vars.textTertiary });

export const footer = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: vars.space2,
paddingLeft: vars.space2
});

export const hint = style({
color: vars.textTertiary,
fontSize: vars.fontSizeXs
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { AssistantComposer, type AssistantComposerProps } from './AssistantComposer';

const renderComposer = (props: Partial<AssistantComposerProps> = {}) => {
const onChange = vi.fn();
const onSubmit = vi.fn();
render(<AssistantComposer value="" onChange={onChange} onSubmit={onSubmit} {...props} />);
return { onChange, onSubmit };
};

const input = () => screen.getByRole('textbox', { name: 'Question' }) as HTMLTextAreaElement;
const send = () => screen.getByRole('button', { name: /Send/ }) as HTMLButtonElement;
const pressEnter = (init: Partial<KeyboardEventInit> = {}) => fireEvent.keyDown(input(), { key: 'Enter', ...init });

describe('AssistantComposer', () => {
it('passes typed text to the parent', () => {
Comment thread
voidmatcha marked this conversation as resolved.
const { onChange } = renderComposer();
fireEvent.change(input(), { target: { value: 'Explain this notebook' } });
expect(onChange).toHaveBeenCalledWith('Explain this notebook');
// The parent owns the value, so the field keeps showing what the parent passed.
expect(input().value).toBe('');
});

it('shows and sends the value the parent sets later', () => {
const onSubmit = vi.fn();
const { rerender } = render(<AssistantComposer value="" onChange={vi.fn()} onSubmit={onSubmit} />);
rerender(<AssistantComposer value="Explain this notebook" onChange={vi.fn()} onSubmit={onSubmit} />);
expect(input().value).toBe('Explain this notebook');
fireEvent.click(send());
expect(onSubmit).toHaveBeenCalledWith('Explain this notebook');
});

it('sends the trimmed question from the button and from Enter', () => {
const { onSubmit } = renderComposer({ value: ' Explain this notebook\n' });
fireEvent.click(send());
pressEnter();
expect(onSubmit.mock.calls).toEqual([['Explain this notebook'], ['Explain this notebook']]);
});

it.each([
['empty', ''],
['whitespace only', ' \n\t ']
])('does not send %s input', (_, value) => {
const { onSubmit } = renderComposer({ value });
expect(send().disabled).toBe(true);
pressEnter();
fireEvent.submit(input().form!);
expect(onSubmit).not.toHaveBeenCalled();
});

it('does not send while read-only and says why', () => {
const { onSubmit } = renderComposer({
value: 'Explain this notebook',
readOnly: true,
readOnlyReason: 'This conversation belongs to another user.'
});
expect(input().disabled).toBe(true);
expect(send().disabled).toBe(true);
expect(screen.getByRole('note').textContent).toBe('This conversation belongs to another user.');
pressEnter();
fireEvent.submit(input().form!);
expect(onSubmit).not.toHaveBeenCalled();
});

it('keeps the question visible but does not send it again while sending', () => {
const { onSubmit } = renderComposer({ value: 'Explain this notebook', sending: true });
expect(input().readOnly).toBe(true);
expect(input().value).toBe('Explain this notebook');
expect(send().disabled).toBe(true);
expect(screen.queryByRole('note')).toBeNull();
pressEnter();
fireEvent.submit(input().form!);
expect(onSubmit).not.toHaveBeenCalled();
});

it('leaves Shift+Enter and Enter that confirms an IME composition to the input', () => {
const { onSubmit } = renderComposer({ value: '질문' });
expect(pressEnter({ shiftKey: true })).toBe(true);
expect(pressEnter({ isComposing: true })).toBe(true);
expect(pressEnter({ keyCode: 229 })).toBe(true);
expect(onSubmit).not.toHaveBeenCalled();
expect(pressEnter()).toBe(false);
expect(onSubmit).toHaveBeenCalledWith('질문');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { fn } from 'storybook/test';
import { AssistantComposer } from './AssistantComposer';

const meta = {
title: 'Assistant/Composer',
component: AssistantComposer,
args: { value: '', onChange: fn(), onSubmit: fn() }
} satisfies Meta<typeof AssistantComposer>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Empty: Story = {};

export const Draft: Story = {
args: { value: 'Explain how this notebook calculates revenue.' }
};

export const WhitespaceOnly: Story = {
args: { value: ' \n ' }
};

export const Sending: Story = {
args: { value: 'Explain how this notebook calculates revenue.', sending: true }
};

export const ReadOnly: Story = {
args: { readOnly: true, readOnlyReason: 'This conversation belongs to another user.' }
};

/** Type and send: the question is logged in the Actions panel and the input is cleared, as a parent would do. */
export const Playground: Story = {
render: function Render(args) {
const [value, setValue] = useState(args.value);
return (
<AssistantComposer
{...args}
value={value}
onChange={next => {
setValue(next);
args.onChange(next);
}}
onSubmit={question => {
args.onSubmit(question);
setValue('');
}}
/>
);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { ArrowUpOutlined, LockOutlined } from '@ant-design/icons';
import { Button, Input } from 'antd';
import { useId, type FormEvent, type KeyboardEvent } from 'react';
import * as styles from './AssistantComposer.css';

export interface AssistantComposerProps {
value: string;
onChange: (value: string) => void;
/** Receives the question without surrounding whitespace. Clearing the input afterwards is up to the parent. */
onSubmit: (question: string) => void;
/** The question cannot be sent here, for example in a conversation started by another user. */
readOnly?: boolean;
readOnlyReason?: string;
/** A question is on its way: the input keeps its text but cannot be changed or sent again. */
sending?: boolean;
}

export const AssistantComposer = ({
value,
onChange,
onSubmit,
readOnly = false,
readOnlyReason = "You can't send messages to this conversation.",
sending = false
}: AssistantComposerProps) => {
const hintId = useId();
const question = value.trim();
// The server rejects a blank question, and it accepts one question per conversation at a time.
const canSubmit = question !== '' && !readOnly && !sending;

const submit = () => {
if (canSubmit) onSubmit(question);
};
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
submit();
};
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
// Enter that confirms an IME composition (such as Korean or Japanese input) is not a request to send.
// Safari fires compositionend before that Enter, so it arrives with isComposing false and keyCode 229.
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing || event.keyCode === 229) return;
event.preventDefault();
submit();
};

return (
<form className={styles.composer} aria-label="Ask the assistant" onSubmit={handleSubmit}>
{readOnly && (
<p className={styles.note} role="note">
<LockOutlined aria-hidden="true" />
<span>{readOnlyReason}</span>
</p>
)}
<div className={styles.field} data-disabled={readOnly || sending || undefined}>
<Input.TextArea
className={styles.input}
variant="borderless"
autoSize={{ minRows: 2, maxRows: 8 }}
aria-label="Question"
aria-describedby={hintId}
placeholder="Ask about this notebook"
value={value}
disabled={readOnly}
readOnly={sending}
onChange={event => onChange(event.target.value)}
onKeyDown={handleKeyDown}
/>
<div className={styles.footer}>
<small id={hintId} className={styles.hint}>
Shift + Enter for a new line
</small>
<Button type="primary" htmlType="submit" icon={<ArrowUpOutlined />} loading={sending} disabled={!canSubmit}>
Send
</Button>
</div>
</div>
</form>
);
};