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>
+  );
+};