diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.css.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.css.ts new file mode 100644 index 00000000000..0b93ea94486 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.css.ts @@ -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 +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.spec.tsx new file mode 100644 index 00000000000..1e0bc0d865b --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.spec.tsx @@ -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 = {}) => { + const onChange = vi.fn(); + const onSubmit = vi.fn(); + render(); + 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 = {}) => fireEvent.keyDown(input(), { key: 'Enter', ...init }); + +describe('AssistantComposer', () => { + it('passes typed text to the parent', () => { + 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(); + rerender(); + 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('질문'); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.stories.tsx new file mode 100644 index 00000000000..46448d55413 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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 ( + { + setValue(next); + args.onChange(next); + }} + onSubmit={question => { + args.onSubmit(question); + setValue(''); + }} + /> + ); + } +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.tsx new file mode 100644 index 00000000000..d0ede4c8099 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.tsx @@ -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) => { + // 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 ( +
+ {readOnly && ( +

+

+ )} +
+ onChange(event.target.value)} + onKeyDown={handleKeyDown} + /> +
+ + Shift + Enter for a new line + + +
+
+
+ ); +};