Repository navigation
[ZEPPELIN-6746] Add AI Assistant question composer component #5569
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+320
−0
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
40d5ea9
[ZEPPELIN-6746] Add AI Assistant question composer
kimyenac cd34ebf
[ZEPPELIN-6746] Add AI Assistant composer stories
kimyenac 8d03894
[ZEPPELIN-6746] Do not send on the Enter that confirms an IME composi…
voidmatcha 560f1ef
[ZEPPELIN-6746] Pin the controlled value of the composer in tests
voidmatcha 22d84b0
[ZEPPELIN-6746] Raise the composer placeholder contrast
kimyenac File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
67 changes: 67 additions & 0 deletions
67
...in-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.css.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| }); |
98 changes: 98 additions & 0 deletions
98
...-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.spec.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', () => { | ||
| 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('질문'); | ||
| }); | ||
| }); | ||
64 changes: 64 additions & 0 deletions
64
...b-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.stories.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(''); | ||
| }} | ||
| /> | ||
| ); | ||
| } | ||
| }; |
91 changes: 91 additions & 0 deletions
91
zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| }; |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.