This is an automated email from the ASF dual-hosted git repository.
voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new 37ddc52e72 [ZEPPELIN-6746] Add AI Assistant question composer component
37ddc52e72 is described below
commit 37ddc52e72d44b346661bf840b226537b0e4df64
Author: 김예나 <[email protected]>
AuthorDate: Tue Oct 20 20:09:53 2026 +0900
[ZEPPELIN-6746] Add AI Assistant question composer component
### What is this PR for?
Add the AI Assistant question composer: a controlled component that takes a
value, change and submit callbacks, and read-only and sending states. Nothing
is sent anywhere. The parent receives the question and decides what to do with
it. The component lives in `projects/zeppelin-react/src/entities/assistant/ui`.
Commits:
1. **Question composer**
* `AssistantComposer` is a `<form>` with an antd `Input.TextArea`
(auto-sizing, 2–8 rows) and a **Send** `Button`.
* The value is controlled. `onSubmit` receives the question with
surrounding whitespace trimmed. Clearing the input afterwards is left to the
parent, so it can keep the text if sending fails.
* Nothing is submitted, by the button, Enter or the form, when:
* the input is empty or whitespace only (the server rejects a blank
prompt with 400),
* `readOnly` is set (for example a conversation owned by another
user). The textarea is disabled and a note explains why (`readOnlyReason`, with
a default).
* `sending` is set (the server accepts one run per conversation). The
textarea becomes read-only, so the question stays visible, and the button shows
a spinner.
* Enter sends and Shift+Enter adds a new line. An Enter that confirms an
IME composition (Korean, Japanese, …) does not send. Otherwise the last
syllable of a Korean question would send it. Commit 3 extends this to Safari.
2. **Stories**
* `Assistant/Composer`: Empty, Draft, WhitespaceOnly, Sending, ReadOnly.
* `Assistant/Composer/Playground` keeps its own value: type, send with
the button or Enter, see `onSubmit` in the Actions panel and the input cleared.
3. **Review: do not send on the Enter that confirms an IME composition in
Safari** (by <at>voidmatcha)
* Safari fires `compositionend` before that Enter, so it arrives with
`isComposing: false` and `keyCode: 229`. The check now covers both.
4. **Review: pin the controlled value of the composer in tests** (by
<at>voidmatcha)
* The field keeps showing the parent's value when `onChange` is ignored,
and shows and sends a value the parent sets later. Both tests fail if `value`
becomes `defaultValue`.
5. **Review: raise the composer placeholder contrast**
* antd's placeholder colour was about 1.8:1 (light) and 2.3:1 (dark). It
now uses the theme's `textTertiary`, which the theme lifts to 4.5:1. Measured
in Storybook: 6.10:1 / 6.39:1 on the panel, 5.84:1 on the read-only field.
Styling uses `<at>vanilla-extract/css` through the Assistant theme contract
only. The field draws a single focus ring for the textarea inside it. The
HTML/CSS screens in `voidmatcha/assistant-storybook-share` were used as a
design reference. The code is written from scratch.
**Out of scope** (per the issue): per-conversation draft persistence,
actual sending, Stop and retry. These belong to panel assembly (ZEPPELIN-6748)
or follow-up issues.
### What type of PR is it?
Feature
### Todos
* [ ] - Task
### What is the Jira issue?
* https://issues.apache.org/jira/browse/ZEPPELIN-6746
### How should this be tested?
In `zeppelin-web-angular/projects/zeppelin-react`:
* `npm ci`, `npm test` (247 passed, 8 new), `npm run typecheck`, `npm run
lint` and `npm run build:storybook` all pass.
* `typecheck` needs `npm ci` in `zeppelin-web-angular` first, because the
SDK resolves `rxjs` from there.
* The new files also pass the frontend's `prettier --check`.
* `npm run storybook`: every story above checked in both themes, including
typing Korean and pressing Enter mid-composition in Playground (Chrome). Safari
was not tried; commit 3 is covered by a `keyCode: 229` unit test.
* The spec was mutation-checked: dropping the IME check, the Safari
`keyCode` check, the trim, the sending guard, the read-only guard or the
read-only textarea while sending, or using `defaultValue`, each fails the
intended test.
* `npm run test:storybook -- --workers=1`: all 52 tests pass. It runs with
one worker because the default 3 workers made the existing `python3 -m
http.server` web server drop requests on the earlier base, independent of these
stories (see #5567).
### Screenshots (if appropriate)
See the `Assistant/Composer` stories.
### Questions:
* Does the license files need to update? No. There are no new dependencies.
* Is there breaking changes for older versions? No. Only new files are
added.
* Does this needs documentation? No.
Closes #5569 from kimyenac/ZEPPELIN-6746.
Signed-off-by: YONGJAE LEE <[email protected]>
---
.../entities/assistant/ui/AssistantComposer.css.ts | 67 +++++++++++++++
.../assistant/ui/AssistantComposer.spec.tsx | 98 ++++++++++++++++++++++
.../assistant/ui/AssistantComposer.stories.tsx | 64 ++++++++++++++
.../entities/assistant/ui/AssistantComposer.tsx | 91 ++++++++++++++++++++
4 files changed, 320 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..0b93ea9448
--- /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 0000000000..1e0bc0d865
--- /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<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('질문');
+ });
+});
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 0000000000..46448d5541
--- /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<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('');
+ }}
+ />
+ );
+ }
+};
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..d0ede4c809
--- /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<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>
+ );
+};