This is an automated email from the ASF dual-hosted git repository. voidmatcha pushed a commit to branch branch-voidmatcha-assistant-integration-temp in repository https://gitbox.apache.org/repos/asf/zeppelin.git
commit 1fad831ffb5b8b559e3d6c2c134adba849560b89 Author: kimyenac <[email protected]> AuthorDate: Tue Oct 13 16:24:11 2026 +0900 [ZEPPELIN-6746] Add AI Assistant question composer --- .../entities/assistant/ui/AssistantComposer.css.ts | 65 ++++++++++++++++ .../assistant/ui/AssistantComposer.spec.tsx | 85 ++++++++++++++++++++ .../entities/assistant/ui/AssistantComposer.tsx | 90 ++++++++++++++++++++++ 3 files changed, 240 insertions(+) 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 0000000000..57f781c00e --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.css.ts @@ -0,0 +1,65 @@ +/* + * 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' }); + +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 0000000000..e643465d29 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.spec.tsx @@ -0,0 +1,85 @@ +/* + * 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'); + }); + + 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.'); + 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(onSubmit).not.toHaveBeenCalled(); + expect(pressEnter()).toBe(false); + expect(onSubmit).toHaveBeenCalledWith('질문'); + }); +}); 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 0000000000..95b585874b --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.tsx @@ -0,0 +1,90 @@ +/* + * 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. + if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) 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> + ); +};
