You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@zeppelin.apache.org by yo...@apache.org on 2026/10/20 11:10:00 UTC
(zeppelin) branch master updated: [ZEPPELIN-6746] Add AI Assistant question composer component
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: 김예나 <10...@users.noreply.github.com>
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 <de...@gmail.com>
---
.../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>
+ );
+};