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

Reply via email to