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/17 00:22:31 UTC

(zeppelin) branch branch-voidmatcha-assistant-integration-temp created (now d4702bde1d)

This is an automated email from the ASF dual-hosted git repository.

voidmatcha pushed a change to branch branch-voidmatcha-assistant-integration-temp
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


      at d4702bde1d Merge #5575 rebased onto master

This branch includes the following new commits:

     new 4fcdd3ddba [ZEPPELIN-6745] Add AI Assistant message item and answer view types
     new 1fad831ffb [ZEPPELIN-6746] Add AI Assistant question composer
     new dbe91174bd [ZEPPELIN-6745] Add AI Assistant answer status, tool log, and error displays
     new 5783b38d0f [ZEPPELIN-6746] Add AI Assistant composer stories
     new 69dbc89df3 [ZEPPELIN-6745] Add stories for AI Assistant messages and answer states
     new d72d9689cc [ZEPPELIN-6746] Do not send on the Enter that confirms an IME composition in Safari
     new 0318e5688a [ZEPPELIN-6745] Remove the temporary Assistant theme check story
     new 5639220dd5 Merge #5563 Assistant transport
     new e9a9726ee3 [ZEPPELIN-6746] Pin the controlled value of the composer in tests
     new c6ae924339 [ZEPPELIN-6747] Add AI Assistant conversation list and header components
     new 9fd079b214 [ZEPPELIN-6745] Align answer UI with segmented history
     new 1dc27a7c78 Merge #5565 Assistant sidebar shell
     new c9a0944a24 [ZEPPELIN-6746] Raise the composer placeholder contrast
     new bfbb0abb5e [ZEPPELIN-6747] Fill the Assistant conversation fixtures from the master contract
     new d4372cdd18 Merge #5567 rebased onto master
     new 9e8ee471fa Merge #5569 rebased onto master
     new d4702bde1d Merge #5575 rebased onto master

The 17 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(zeppelin) 12/17: Merge #5565 Assistant sidebar shell

Posted by yo...@apache.org.
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 1dc27a7c787eaa4c84041a4a3f08f606ae4df95d
Merge: 5639220dd5 be696833be
Author: YONGJAE LEE <de...@gmail.com>
AuthorDate: Sat Oct 17 09:20:54 2026 +0900

    Merge #5565 Assistant sidebar shell

 .../models/notebook-assistant-page.ts}             |  23 +--
 .../notebook/assistant/assistant-shell.spec.ts     |  60 +++++++
 zeppelin-web-angular/e2e/utils.ts                  |   1 +
 .../projects/zeppelin-react/README.md              |   2 +-
 .../projects/zeppelin-react/src/main.ts            |   1 +
 .../src/pages/AssistantWorkspace.css.ts            | 113 +++++++++++++
 .../src/pages/AssistantWorkspace.spec.tsx          | 183 +++++++++++++++++++++
 .../src/pages/AssistantWorkspace.tsx               | 129 +++++++++++++++
 .../assistant-workspace/lib/usePanelResize.ts      | 100 +++++++++++
 .../projects/zeppelin-react/webpack.config.js      |   3 +-
 .../projects/zeppelin-sdk/src/assistant-ui.ts      |  11 ++
 .../assistant/assistant-host.component.spec.ts     |  84 ++++++++++
 .../notebook/assistant/assistant-host.component.ts | 120 ++++++++++++++
 .../notebook/assistant/assistant-slots.spec.ts     |  39 +++++
 .../notebook/assistant/assistant-slots.ts          |  58 +++++++
 .../workspace/notebook/notebook.component.html     |  13 +-
 .../pages/workspace/notebook/notebook.component.ts |   8 +-
 .../pages/workspace/notebook/notebook.module.ts    |   4 +
 .../notebook/sidebar/sidebar.component.html        |   1 +
 .../notebook/sidebar/sidebar.component.spec.ts     |  35 ++++
 .../notebook/sidebar/sidebar.component.ts          |  29 +++-
 .../src/app/services/react-feature.service.spec.ts |  25 +++
 .../src/app/services/react-feature.service.ts      |   7 +-
 23 files changed, 1030 insertions(+), 19 deletions(-)


(zeppelin) 05/17: [ZEPPELIN-6745] Add stories for AI Assistant messages and answer states

Posted by yo...@apache.org.
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 69dbc89df31fc7d94e1075a3ada32e4e3e966ba7
Author: kimyenac <ye...@gmail.com>
AuthorDate: Mon Oct 12 12:53:50 2026 +0900

    [ZEPPELIN-6745] Add stories for AI Assistant messages and answer states
---
 .../assistant/ui/AssistantAnswer.stories.tsx       | 88 ++++++++++++++++++++++
 .../assistant/ui/AssistantMessageItem.stories.tsx  | 62 +++++++++++++++
 2 files changed, 150 insertions(+)

diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx
new file mode 100644
index 0000000000..cedeff5d6e
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx
@@ -0,0 +1,88 @@
+/*
+ * 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 { AssistantConnectionError, AssistantStreamError } from '../model/assistantContract';
+import type { AssistantAnswerView } from './answerView';
+import { AssistantAnswer } from './AssistantAnswer';
+
+const partial = 'The notebook groups orders by region and';
+const reply = 'The notebook groups orders by region and ranks them by total revenue.';
+
+/** One fixture per state, in the order a run passes through them. */
+const fixtures = {
+  loading: { status: 'loading' },
+  streaming: { status: 'streaming', content: partial },
+  toolRunning: {
+    status: 'streaming',
+    content: '',
+    tools: [
+      { id: 't1', name: 'list_paragraphs', status: 'done' },
+      { id: 't2', name: 'list_paragraphs', status: 'running' }
+    ]
+  },
+  completed: { status: 'done', message: { id: 'a1', role: 'assistant', content: reply } },
+  completedWithTools: {
+    status: 'done',
+    message: {
+      id: 'a1',
+      role: 'assistant',
+      content: reply,
+      toolCalls: [
+        { id: 't1', name: 'list_paragraphs' },
+        { id: 't2', name: 'list_paragraphs' }
+      ]
+    }
+  },
+  failed: { status: 'error', kind: 'failed', message: 'The model request failed.' },
+  streamError: {
+    status: 'error',
+    kind: 'stream',
+    message: new AssistantStreamError().message,
+    content: partial,
+    tools: [{ id: 't1', name: 'list_paragraphs', status: 'done' }]
+  },
+  connectionLost: {
+    status: 'error',
+    kind: 'connection',
+    message: new AssistantConnectionError().message,
+    content: partial
+  }
+} satisfies Record<string, AssistantAnswerView>;
+
+type FixtureName = keyof typeof fixtures;
+
+const meta = {
+  title: 'Assistant/Status',
+  component: AssistantAnswer,
+  args: { view: fixtures.loading, onRetry: () => {} }
+} satisfies Meta<typeof AssistantAnswer>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Loading: Story = {};
+export const Streaming: Story = { args: { view: fixtures.streaming } };
+export const ToolRunning: Story = { args: { view: fixtures.toolRunning } };
+export const Completed: Story = { args: { view: fixtures.completed } };
+export const CompletedWithTools: Story = { args: { view: fixtures.completedWithTools } };
+export const Failed: Story = { args: { view: fixtures.failed } };
+export const StreamError: Story = { args: { view: fixtures.streamError } };
+export const ConnectionLost: Story = { args: { view: fixtures.connectionLost } };
+export const ErrorWithoutRetry: Story = { args: { view: fixtures.failed, onRetry: undefined } };
+
+/** Switch the fixture from the controls panel to check that each state fully replaces the last. */
+export const StateSwitcher: StoryObj<{ state: FixtureName }> = {
+  args: { state: 'loading' },
+  argTypes: { state: { control: 'select', options: Object.keys(fixtures) } },
+  render: ({ state }) => <AssistantAnswer view={fixtures[state]} onRetry={() => {}} />
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx
new file mode 100644
index 0000000000..75931eb2b7
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx
@@ -0,0 +1,62 @@
+/*
+ * 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 { AssistantAnswer } from './AssistantAnswer';
+import { AssistantMessageItem } from './AssistantMessageItem';
+
+const question = 'Explain how this notebook calculates revenue.';
+const reply = 'The notebook groups orders by region and ranks them by total revenue.';
+
+const meta = {
+  title: 'Assistant/Message',
+  component: AssistantMessageItem,
+  args: { role: 'user', content: question }
+} satisfies Meta<typeof AssistantMessageItem>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const User: Story = {};
+
+export const CompletedReply: Story = {
+  args: { role: 'assistant', content: reply }
+};
+
+export const StreamingReply: Story = {
+  args: { role: 'assistant', content: 'The notebook groups orders by region and', streaming: true }
+};
+
+export const LongContent: Story = {
+  args: {
+    role: 'assistant',
+    content: [
+      'Paragraph 2 loads `orders` and joins it with `regions` on region_id.',
+      'Paragraph 3 sums amount per region and sorts the totals in descending order.',
+      'A very long identifier such as orders_by_region_with_currency_normalisation_applied_v2 wraps instead of overflowing.'
+    ].join('\n\n')
+  }
+};
+
+export const Conversation: Story = {
+  render: () => (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
+      <AssistantMessageItem role="user" content={question} />
+      <AssistantAnswer
+        view={{
+          status: 'done',
+          message: { id: 'a1', role: 'assistant', content: reply, toolCalls: [{ id: 't1', name: 'list_paragraphs' }] }
+        }}
+      />
+    </div>
+  )
+};


(zeppelin) 09/17: [ZEPPELIN-6746] Pin the controlled value of the composer in tests

Posted by yo...@apache.org.
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 e9a9726ee33297fd411bb51d558c02289a9defb9
Author: YONGJAE LEE <de...@gmail.com>
AuthorDate: Wed Oct 14 01:00:34 2026 +0900

    [ZEPPELIN-6746] Pin the controlled value of the composer in tests
---
 .../src/entities/assistant/ui/AssistantComposer.spec.tsx     | 12 ++++++++++++
 1 file changed, 12 insertions(+)

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
index faf8004c7f..1e0bc0d865 100644
--- 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
@@ -30,6 +30,17 @@ describe('AssistantComposer', () => {
     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', () => {
@@ -59,6 +70,7 @@ describe('AssistantComposer', () => {
     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();
   });


(zeppelin) 02/17: [ZEPPELIN-6746] Add AI Assistant question composer

Posted by yo...@apache.org.
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 <ye...@gmail.com>
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>
+  );
+};


(zeppelin) 07/17: [ZEPPELIN-6745] Remove the temporary Assistant theme check story

Posted by yo...@apache.org.
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 0318e5688aada3e16684889908f1b44bd1efdc65
Author: kimyenac <ye...@gmail.com>
AuthorDate: Mon Oct 12 21:01:55 2026 +0900

    [ZEPPELIN-6745] Remove the temporary Assistant theme check story
---
 .../ui/assistant-theme/AssistantScope.stories.tsx  | 35 ----------------------
 1 file changed, 35 deletions(-)

diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/shared/ui/assistant-theme/AssistantScope.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/shared/ui/assistant-theme/AssistantScope.stories.tsx
deleted file mode 100644
index c39bb9d5a1..0000000000
--- a/zeppelin-web-angular/projects/zeppelin-react/src/shared/ui/assistant-theme/AssistantScope.stories.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * 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 { Button, Space, Typography } from 'antd';
-import type { Meta, StoryObj } from '@storybook/react-vite';
-
-const ThemeSurface = () => (
-  <Space direction="vertical">
-    <Typography.Text>Assistant theme</Typography.Text>
-    <Button type="primary">Primary action</Button>
-    <Button>Secondary action</Button>
-    <Typography.Text type="secondary">Secondary text</Typography.Text>
-  </Space>
-);
-
-const meta = {
-  title: 'Assistant/Setup',
-  component: ThemeSurface,
-  parameters: {
-    docs: { description: { component: 'Temporary theme check. Remove after the Assistant UI stories land.' } }
-  }
-} satisfies Meta<typeof ThemeSurface>;
-
-export default meta;
-type Story = StoryObj<typeof meta>;
-export const TemporaryThemeCheck: Story = {};


(zeppelin) 03/17: [ZEPPELIN-6745] Add AI Assistant answer status, tool log, and error displays

Posted by yo...@apache.org.
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 dbe91174bdbb9a09c3d2430d6d5009f2485f1fad
Author: kimyenac <ye...@gmail.com>
AuthorDate: Mon Oct 12 12:53:40 2026 +0900

    [ZEPPELIN-6745] Add AI Assistant answer status, tool log, and error displays
---
 .../entities/assistant/ui/AssistantAnswer.css.ts   |  91 ++++++++++++++
 .../entities/assistant/ui/AssistantAnswer.spec.tsx | 137 +++++++++++++++++++++
 .../src/entities/assistant/ui/AssistantAnswer.tsx  |  79 ++++++++++++
 .../assistant/ui/AssistantAnswerError.spec.tsx     |  49 ++++++++
 .../entities/assistant/ui/AssistantAnswerError.tsx |  46 +++++++
 .../src/entities/assistant/ui/AssistantToolLog.tsx |  48 ++++++++
 6 files changed, 450 insertions(+)

diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.css.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.css.ts
new file mode 100644
index 0000000000..044307eb43
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.css.ts
@@ -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 { globalStyle, keyframes, style } from '@vanilla-extract/css';
+import { vars } from '@/shared/ui/assistant-theme/AssistantScope.css';
+
+export const progress = style({
+  display: 'flex',
+  alignItems: 'center',
+  gap: vars.space2,
+  color: vars.textSecondary,
+  fontSize: vars.fontSizeSm
+});
+
+const pulse = keyframes({
+  '0%, 80%, 100%': { opacity: 0.25 },
+  '40%': { opacity: 1 }
+});
+
+export const dots = style({
+  display: 'inline-flex',
+  gap: 3
+});
+
+globalStyle(`${dots} > i`, {
+  width: 4,
+  height: 4,
+  borderRadius: '50%',
+  background: vars.accent,
+  animation: `${pulse} 1.2s ${vars.easeStandard} infinite`
+});
+globalStyle(`${dots} > i:nth-child(2)`, { animationDelay: '0.15s' });
+globalStyle(`${dots} > i:nth-child(3)`, { animationDelay: '0.3s' });
+
+export const skeleton = style({
+  marginTop: vars.space1
+});
+
+export const toolLog = style({
+  border: `1px solid ${vars.border}`,
+  borderRadius: vars.radius,
+  background: vars.fill,
+  fontSize: vars.fontSizeSm
+});
+
+export const toolSummary = style({
+  padding: `${vars.space1} ${vars.space2}`,
+  color: vars.textSecondary,
+  cursor: 'pointer'
+});
+
+export const toolList = style({
+  listStyle: 'none',
+  margin: 0,
+  padding: `0 ${vars.space2} ${vars.space2}`,
+  display: 'flex',
+  flexDirection: 'column',
+  gap: vars.space1
+});
+
+export const toolItem = style({
+  display: 'flex',
+  alignItems: 'center',
+  gap: vars.space2,
+  color: vars.text
+});
+
+export const toolIcon = style({
+  display: 'inline-flex',
+  width: 14,
+  justifyContent: 'center',
+  color: vars.textSecondary
+});
+
+export const toolDone = style({
+  color: vars.success
+});
+
+export const toolName = style({
+  fontFamily: vars.fontMono,
+  overflowWrap: 'anywhere'
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx
new file mode 100644
index 0000000000..7531cf429e
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx
@@ -0,0 +1,137 @@
+/*
+ * 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 { render, screen, within } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import type { AssistantAnswerView } from './answerView';
+import { AssistantAnswer } from './AssistantAnswer';
+
+const answer = () => screen.getByRole('article', { name: 'Assistant answer' });
+
+describe('AssistantAnswer', () => {
+  it('shows only the current state as the run moves from loading to done', () => {
+    const { rerender } = render(<AssistantAnswer view={{ status: 'loading' }} />);
+    expect(screen.getByRole('status').textContent).toBe('Thinking…');
+    expect(answer().getAttribute('aria-busy')).toBe('true');
+
+    rerender(
+      <AssistantAnswer
+        view={{
+          status: 'streaming',
+          content: 'Revenue is grouped',
+          tools: [{ id: 't1', name: 'list_paragraphs', status: 'done' }]
+        }}
+      />
+    );
+    expect(screen.getByRole('status').textContent).toBe('Writing answer…');
+    expect(answer().textContent).toContain('Revenue is grouped');
+
+    rerender(
+      <AssistantAnswer
+        view={{
+          status: 'done',
+          message: {
+            id: 'm1',
+            role: 'assistant',
+            content: 'Revenue is grouped by region.',
+            toolCalls: [{ id: 't1', name: 'list_paragraphs' }]
+          }
+        }}
+      />
+    );
+    expect(screen.queryByRole('status')).toBeNull();
+    expect(screen.queryByRole('alert')).toBeNull();
+    expect(answer().hasAttribute('aria-busy')).toBe(false);
+    expect(answer().textContent).toContain('Revenue is grouped by region.');
+    expect(screen.getByText('Used 1 tool')).toBeTruthy();
+  });
+
+  it('names the running tool in the progress line and marks each tool with its state', () => {
+    render(
+      <AssistantAnswer
+        view={{
+          status: 'streaming',
+          content: '',
+          tools: [
+            { id: 't1', name: 'list_paragraphs', status: 'done' },
+            { id: 't2', name: 'read_results', status: 'running' }
+          ]
+        }}
+      />
+    );
+    expect(screen.getByRole('status').textContent).toBe('Running read_results…');
+    expect(screen.getByText('Using 2 tools')).toBeTruthy();
+    const [first, second] = screen.getAllByRole('listitem');
+    expect(within(first).getByRole('img', { name: 'Done' })).toBeTruthy();
+    expect(within(second).getByRole('img', { name: 'Running' })).toBeTruthy();
+  });
+
+  it('opens the tool log while a tool runs and collapses it when the answer completes', () => {
+    const running: AssistantAnswerView = {
+      status: 'streaming',
+      content: '',
+      tools: [{ id: 't1', name: 'list_paragraphs', status: 'running' }]
+    };
+    const toolLog = () => screen.getByText(/tool$/).closest('details')!;
+    const { rerender } = render(<AssistantAnswer view={running} />);
+    expect(toolLog().open).toBe(true);
+
+    // A user who collapses the log keeps it collapsed while more of the answer arrives.
+    toolLog().open = false;
+    rerender(<AssistantAnswer view={{ ...running, content: 'Revenue' }} />);
+    expect(toolLog().open).toBe(false);
+
+    rerender(
+      <AssistantAnswer
+        view={{
+          status: 'done',
+          message: {
+            id: 'm1',
+            role: 'assistant',
+            content: 'Revenue',
+            toolCalls: [{ id: 't1', name: 'list_paragraphs' }]
+          }
+        }}
+      />
+    );
+    expect(toolLog().open).toBe(false);
+  });
+
+  it('labels a tool whose completion event carried no name', () => {
+    render(<AssistantAnswer view={{ status: 'streaming', content: '', tools: [{ id: 't1', status: 'running' }] }} />);
+    expect(screen.getByRole('status').textContent).toBe('Running Tool…');
+  });
+
+  it('keeps the partial answer and replaces the progress line with the error', () => {
+    const view: AssistantAnswerView = {
+      status: 'error',
+      kind: 'stream',
+      message: 'The assistant stopped responding before the reply finished. Retry the request.',
+      content: 'Revenue is grouped'
+    };
+    render(<AssistantAnswer view={view} />);
+    expect(screen.queryByRole('status')).toBeNull();
+    expect(answer().hasAttribute('aria-busy')).toBe(false);
+    expect(answer().textContent).toContain('Revenue is grouped');
+    expect(screen.getByRole('alert').textContent).toContain('stopped responding');
+  });
+
+  it('renders answer bodies through the given renderer', () => {
+    render(
+      <AssistantAnswer
+        view={{ status: 'done', message: { id: 'm1', role: 'assistant', content: '**bold**' } }}
+        renderContent={content => <strong>{content.replaceAll('*', '')}</strong>}
+      />
+    );
+    expect(answer().querySelector('strong')?.textContent).toBe('bold');
+  });
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx
new file mode 100644
index 0000000000..b0f6fbecbc
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx
@@ -0,0 +1,79 @@
+/*
+ * 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 { Skeleton } from 'antd';
+import type { AssistantContentRenderer, AssistantAnswerView, AssistantToolActivity } from './answerView';
+import { AssistantAnswerError } from './AssistantAnswerError';
+import { AssistantMessageItem } from './AssistantMessageItem';
+import { AssistantToolLog, toolLabel } from './AssistantToolLog';
+import * as styles from './AssistantAnswer.css';
+
+const Progress = ({ text }: { text: string }) => (
+  <div role="status" className={styles.progress}>
+    <span className={styles.dots} aria-hidden="true">
+      <i />
+      <i />
+      <i />
+    </span>
+    {text}
+  </div>
+);
+
+const progressText = (content: string, tools: AssistantToolActivity[]) => {
+  const running = tools.find(tool => tool.status === 'running');
+  if (running) return `Running ${toolLabel(running)}…`;
+  return content ? 'Writing answer…' : 'Thinking…';
+};
+
+export interface AssistantAnswerProps {
+  view: AssistantAnswerView;
+  /** Called from the error display. */
+  onRetry?: () => void;
+  renderContent?: AssistantContentRenderer;
+}
+
+/** One assistant answer in whichever state its run is in. Each state replaces the last, so only one shows at a time. */
+export const AssistantAnswer = ({ view, onRetry, renderContent }: AssistantAnswerProps) => {
+  switch (view.status) {
+    case 'loading':
+      return (
+        <AssistantMessageItem role="assistant" content="" streaming>
+          <Progress text="Thinking…" />
+          <Skeleton className={styles.skeleton} active title={false} paragraph={{ rows: 2, width: ['92%', '64%'] }} />
+        </AssistantMessageItem>
+      );
+    case 'streaming': {
+      const tools = view.tools ?? [];
+      return (
+        <AssistantMessageItem role="assistant" content={view.content} streaming renderContent={renderContent}>
+          <AssistantToolLog tools={tools} />
+          <Progress text={progressText(view.content, tools)} />
+        </AssistantMessageItem>
+      );
+    }
+    case 'done': {
+      const tools = (view.message.toolCalls ?? []).map(call => ({ ...call, status: 'done' as const }));
+      return (
+        <AssistantMessageItem role="assistant" content={view.message.content} renderContent={renderContent}>
+          <AssistantToolLog tools={tools} />
+        </AssistantMessageItem>
+      );
+    }
+    case 'error':
+      return (
+        <AssistantMessageItem role="assistant" content={view.content ?? ''} renderContent={renderContent}>
+          <AssistantToolLog tools={view.tools ?? []} />
+          <AssistantAnswerError kind={view.kind} message={view.message} onRetry={onRetry} />
+        </AssistantMessageItem>
+      );
+  }
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.spec.tsx
new file mode 100644
index 0000000000..644505c632
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.spec.tsx
@@ -0,0 +1,49 @@
+/*
+ * 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 { AssistantConnectionError, AssistantStreamError } from '../model/assistantContract';
+import { AssistantAnswerError } from './AssistantAnswerError';
+
+describe('AssistantAnswerError', () => {
+  it('shows a server failure with a retry action', () => {
+    const onRetry = vi.fn();
+    render(<AssistantAnswerError kind="failed" message="The model request failed." onRetry={onRetry} />);
+    expect(screen.getByRole('alert').textContent).toContain('The model request failed.');
+    fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
+    expect(onRetry).toHaveBeenCalledOnce();
+  });
+
+  it('offers retry when the stream ended early', () => {
+    render(<AssistantAnswerError kind="stream" message={new AssistantStreamError().message} onRetry={() => {}} />);
+    expect(screen.getByRole('alert').textContent).toContain('stopped responding before the reply finished');
+    expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy();
+  });
+
+  it('asks for a reload, not a retry, when the connection dropped', () => {
+    const onRetry = vi.fn();
+    render(
+      <AssistantAnswerError kind="connection" message={new AssistantConnectionError().message} onRetry={onRetry} />
+    );
+    expect(screen.getByRole('alert').textContent).toContain('connection to the server was lost');
+    expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull();
+    fireEvent.click(screen.getByRole('button', { name: 'Reload conversation' }));
+    expect(onRetry).toHaveBeenCalledOnce();
+  });
+
+  it('shows no button when there is nothing to retry', () => {
+    render(<AssistantAnswerError kind="failed" message="The model request failed." />);
+    expect(screen.getByRole('alert')).toBeTruthy();
+    expect(screen.queryByRole('button')).toBeNull();
+  });
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.tsx
new file mode 100644
index 0000000000..ebfb6e80fa
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.tsx
@@ -0,0 +1,46 @@
+/*
+ * 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 { Alert, Button } from 'antd';
+import type { AssistantAnswerErrorKind } from './answerView';
+
+const presentation: Record<AssistantAnswerErrorKind, { type: 'error' | 'warning'; action: string }> = {
+  failed: { type: 'error', action: 'Retry' },
+  stream: { type: 'error', action: 'Retry' },
+  // The answer may have finished on the server; only reloading the conversation shows where it got to.
+  connection: { type: 'warning', action: 'Reload conversation' }
+};
+
+export interface AssistantAnswerErrorProps {
+  kind: AssistantAnswerErrorKind;
+  message: string;
+  /** Without it there is nothing to offer, so no button is shown. */
+  onRetry?: () => void;
+}
+
+export const AssistantAnswerError = ({ kind, message, onRetry }: AssistantAnswerErrorProps) => {
+  const { type, action } = presentation[kind];
+  return (
+    <Alert
+      type={type}
+      showIcon
+      title={message}
+      action={
+        onRetry && (
+          <Button size="small" onClick={onRetry}>
+            {action}
+          </Button>
+        )
+      }
+    />
+  );
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx
new file mode 100644
index 0000000000..46a0a111a6
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx
@@ -0,0 +1,48 @@
+/*
+ * 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 { CheckOutlined } from '@ant-design/icons';
+import { Spin } from 'antd';
+import type { AssistantToolActivity } from './answerView';
+import * as styles from './AssistantAnswer.css';
+
+export const toolLabel = (tool: Pick<AssistantToolActivity, 'name'>) => tool.name || 'Tool';
+
+export interface AssistantToolLogProps {
+  tools: AssistantToolActivity[];
+}
+
+/** The tools behind an answer. Open while one is still running, collapsed once they have all finished. */
+export const AssistantToolLog = ({ tools }: AssistantToolLogProps) => {
+  if (!tools.length) return null;
+  const running = tools.some(tool => tool.status === 'running');
+  const count = `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'}`;
+  return (
+    <details className={styles.toolLog} open={running}>
+      <summary className={styles.toolSummary}>{running ? `Using ${count}` : `Used ${count}`}</summary>
+      <ul className={styles.toolList}>
+        {tools.map(tool => (
+          <li key={tool.id} className={styles.toolItem} data-status={tool.status}>
+            {tool.status === 'running' ? (
+              <span className={styles.toolIcon} role="img" aria-label="Running">
+                <Spin size="small" />
+              </span>
+            ) : (
+              <CheckOutlined className={`${styles.toolIcon} ${styles.toolDone}`} aria-label="Done" />
+            )}
+            <span className={styles.toolName}>{toolLabel(tool)}</span>
+          </li>
+        ))}
+      </ul>
+    </details>
+  );
+};


(zeppelin) 10/17: [ZEPPELIN-6747] Add AI Assistant conversation list and header components

Posted by yo...@apache.org.
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 c6ae9243392b9a700349dd97d5c3b35e709f597e
Author: xhaktm00 <15...@users.noreply.github.com>
AuthorDate: Fri Oct 16 10:59:19 2026 +0900

    [ZEPPELIN-6747] Add AI Assistant conversation list and header components
    
    Add ConversationList and ConversationHeader, the Assistant panel's
    conversation browsing UI, buildable and reviewable without a server.
    
    ConversationList takes conversation data and the selection state as
    props, shows the list grouped into a "Today" section (with a relative
    time) and an "Earlier" section, search with a distinct empty-search
    message, and a delete request per conversation. ConversationHeader
    shows the inner header for the selected conversation. Both report
    every action (new conversation, selection, delete request, search
    term change) through callbacks; actual deletion and its confirmation
    step stay with panel assembly.
    
    Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
---
 .../assistant/ui/ConversationHeader.css.ts         |  35 +++
 .../assistant/ui/ConversationHeader.spec.tsx       |  52 ++++
 .../assistant/ui/ConversationHeader.stories.tsx    |  32 +++
 .../entities/assistant/ui/ConversationHeader.tsx   |  50 ++++
 .../entities/assistant/ui/ConversationList.css.ts  | 106 ++++++++
 .../assistant/ui/ConversationList.spec.tsx         | 273 +++++++++++++++++++++
 .../assistant/ui/ConversationList.stories.tsx      |  69 ++++++
 .../src/entities/assistant/ui/ConversationList.tsx | 172 +++++++++++++
 8 files changed, 789 insertions(+)

diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.css.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.css.ts
new file mode 100644
index 0000000000..c2a93b8397
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.css.ts
@@ -0,0 +1,35 @@
+/*
+ * 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 { style } from '@vanilla-extract/css';
+import { vars } from '@/shared/ui/assistant-theme/AssistantScope.css';
+
+export const header = style({
+  display: 'flex',
+  alignItems: 'center',
+  gap: vars.space2,
+  padding: `${vars.space1} 0`,
+  borderBottom: `1px solid ${vars.border}`
+});
+
+export const title = style({
+  flex: 1,
+  overflow: 'hidden',
+  textOverflow: 'ellipsis',
+  whiteSpace: 'nowrap',
+  fontWeight: 600
+});
+
+export const lock = style({
+  marginRight: vars.space1,
+  color: vars.textTertiary
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.spec.tsx
new file mode 100644
index 0000000000..bda08d1065
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.spec.tsx
@@ -0,0 +1,52 @@
+/*
+ * 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 { render, screen, fireEvent } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import type { AssistantConversation } from '../model/assistantContract';
+import { ConversationHeader } from './ConversationHeader';
+
+const noop = () => {};
+
+describe('ConversationHeader', () => {
+  it('shows the selected conversation title', () => {
+    const conversation: AssistantConversation = { id: 'a', title: 'Explain revenue by region' };
+    render(<ConversationHeader conversation={conversation} onCreate={noop} onDelete={noop} />);
+    expect(screen.getByText('Explain revenue by region')).toBeTruthy();
+  });
+
+  it('shows a placeholder when no conversation is selected', () => {
+    render(<ConversationHeader onCreate={noop} onDelete={noop} />);
+    expect(screen.getByText('No conversation selected')).toBeTruthy();
+  });
+
+  it('offers no delete button for a read-only conversation', () => {
+    const conversation: AssistantConversation = { id: 'a', title: 'Shared', canSendMessage: false };
+    render(<ConversationHeader conversation={conversation} onCreate={noop} onDelete={noop} />);
+    expect(screen.queryByRole('button', { name: 'Delete Shared' })).toBeNull();
+  });
+
+  it('reports the deleted conversation id without asking for confirmation', () => {
+    const onDelete = vi.fn();
+    const conversation: AssistantConversation = { id: 'a', title: 'Explain revenue by region' };
+    render(<ConversationHeader conversation={conversation} onCreate={noop} onDelete={onDelete} />);
+    fireEvent.click(screen.getByRole('button', { name: 'Delete Explain revenue by region' }));
+    expect(onDelete).toHaveBeenCalledWith('a');
+  });
+
+  it('reports a new-conversation request', () => {
+    const onCreate = vi.fn();
+    render(<ConversationHeader onCreate={onCreate} onDelete={noop} />);
+    fireEvent.click(screen.getByRole('button', { name: 'New conversation' }));
+    expect(onCreate).toHaveBeenCalled();
+  });
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.stories.tsx
new file mode 100644
index 0000000000..83e3de509d
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.stories.tsx
@@ -0,0 +1,32 @@
+/*
+ * 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 { fn } from 'storybook/test';
+import type { AssistantConversation } from '../model/assistantContract';
+import { ConversationHeader } from './ConversationHeader';
+
+const current: AssistantConversation = { id: 'a', title: 'Explain how the notebook calculates revenue' };
+const readOnly: AssistantConversation = { id: 'b', title: 'Compare regional totals', canSendMessage: false };
+
+const meta = {
+  title: 'Assistant/ConversationHeader',
+  component: ConversationHeader,
+  args: { conversation: current, onCreate: fn(), onDelete: fn() }
+} satisfies Meta<typeof ConversationHeader>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Current: Story = {};
+export const ReadOnly: Story = { args: { conversation: readOnly } };
+export const NoSelection: Story = { args: { conversation: undefined } };
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.tsx
new file mode 100644
index 0000000000..bf8fcb5bab
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.tsx
@@ -0,0 +1,50 @@
+/*
+ * 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 * as styles from './ConversationHeader.css';
+
+import { DeleteOutlined, LockOutlined, PlusOutlined } from '@ant-design/icons';
+import { Button } from 'antd';
+import { visuallyHidden } from '@/shared/ui/assistant-theme/AssistantScope.css';
+import type { AssistantConversation } from '../model/assistantContract';
+
+export interface ConversationHeaderProps {
+  conversation?: AssistantConversation;
+  onCreate: () => void;
+  onDelete: (conversationId: string) => void;
+}
+
+export const ConversationHeader = ({ conversation, onCreate, onDelete }: ConversationHeaderProps) => {
+  const title = conversation ? conversation.title : 'No conversation selected';
+  const readOnly = conversation?.canSendMessage === false;
+  return (
+    <header className={styles.header}>
+      {readOnly && (
+        <>
+          <LockOutlined className={styles.lock} aria-hidden="true" />
+          <span className={visuallyHidden}>Read-only</span>
+        </>
+      )}
+      <span className={styles.title}>{title}</span>
+      <Button size="small" type="text" icon={<PlusOutlined />} aria-label="New conversation" onClick={onCreate} />
+      {conversation && !readOnly && (
+        <Button
+          size="small"
+          type="text"
+          icon={<DeleteOutlined />}
+          aria-label={`Delete ${conversation.title}`}
+          onClick={() => onDelete(conversation.id)}
+        />
+      )}
+    </header>
+  );
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.css.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.css.ts
new file mode 100644
index 0000000000..5dead331bb
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.css.ts
@@ -0,0 +1,106 @@
+/*
+ * 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 { style } from '@vanilla-extract/css';
+import { vars, visuallyHidden } from '@/shared/ui/assistant-theme/AssistantScope.css';
+
+export { visuallyHidden };
+
+export const list = style({
+  display: 'flex',
+  flexDirection: 'column',
+  gap: vars.space2
+});
+
+export const header = style({
+  display: 'flex',
+  alignItems: 'center',
+  gap: vars.space2,
+  padding: `${vars.space1} 0`
+});
+
+export const title = style({
+  flex: 1,
+  margin: 0,
+  fontSize: vars.fontSize,
+  fontWeight: 600
+});
+
+export const search = style({
+  padding: `0 ${vars.space1}`
+});
+
+export const sectionLabel = style({
+  margin: `${vars.space3} 0 0`,
+  padding: `0 ${vars.space2}`,
+  color: vars.textSecondary,
+  fontSize: vars.fontSizeSm,
+  selectors: {
+    '&:first-child': { marginTop: 0 }
+  }
+});
+
+export const items = style({
+  listStyle: 'none',
+  marginTop: `calc(-1 * ${vars.space2})`,
+  marginBottom: 0,
+  padding: 0,
+  display: 'flex',
+  flexDirection: 'column'
+});
+
+export const row = style({
+  display: 'flex',
+  alignItems: 'center',
+  gap: vars.space1
+});
+
+export const rowButton = style({
+  flex: 1,
+  display: 'flex',
+  flexDirection: 'column',
+  alignItems: 'flex-start',
+  minWidth: 0,
+  padding: vars.space2,
+  border: 'none',
+  background: 'none',
+  color: 'inherit',
+  font: 'inherit',
+  textAlign: 'left',
+  borderRadius: vars.radius,
+  cursor: 'pointer',
+  selectors: {
+    '&:hover': { background: vars.fill },
+    '&[aria-current="true"]': { background: vars.accentBg, color: vars.accentText }
+  }
+});
+
+export const rowTitle = style({
+  overflow: 'hidden',
+  textOverflow: 'ellipsis',
+  whiteSpace: 'nowrap',
+  width: '100%'
+});
+
+export const rowMeta = style({
+  display: 'flex',
+  alignItems: 'center',
+  gap: vars.space1,
+  color: vars.textTertiary,
+  fontSize: vars.fontSizeXs
+});
+
+export const empty = style({
+  padding: vars.space4,
+  textAlign: 'center',
+  color: vars.textSecondary
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.spec.tsx
new file mode 100644
index 0000000000..6909e74eb1
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.spec.tsx
@@ -0,0 +1,273 @@
+/*
+ * 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 { render, screen, fireEvent, within } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import type { AssistantConversation } from '../model/assistantContract';
+import { ConversationList } from './ConversationList';
+
+const NOW = new Date(2026, 9, 15, 12).getTime();
+
+const conversations: AssistantConversation[] = [
+  { id: 'a', title: 'Explain revenue by region', updatedAt: new Date(2026, 9, 15, 11).toISOString() },
+  { id: 'b', title: 'Draft a query', updatedAt: new Date(2026, 9, 10, 9).toISOString() },
+  { id: 'c', title: 'Shared analysis', canSendMessage: false, updatedAt: new Date(2026, 9, 9, 9).toISOString() }
+];
+
+const noop = () => {};
+
+describe('ConversationList', () => {
+  it('shows an empty-list message when there are no conversations', () => {
+    render(
+      <ConversationList
+        conversations={[]}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    expect(screen.getByRole('status').textContent).toBe('No conversations yet.');
+  });
+
+  it('shows a different message for a search with no matches', () => {
+    render(
+      <ConversationList
+        conversations={conversations}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    fireEvent.change(screen.getByRole('textbox', { name: 'Search conversations' }), {
+      target: { value: 'nothing matches this' }
+    });
+    expect(screen.getByRole('status').textContent).toBe('No conversations match your search.');
+  });
+
+  it('filters by title, case-insensitively and trimmed', () => {
+    render(
+      <ConversationList
+        conversations={conversations}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    fireEvent.change(screen.getByRole('textbox', { name: 'Search conversations' }), {
+      target: { value: '  REVENUE  ' }
+    });
+    expect(screen.getByText('Explain revenue by region')).toBeTruthy();
+    expect(screen.queryByText('Draft a query')).toBeNull();
+  });
+
+  it('reports the search term through the callback', () => {
+    const onSearchTermChange = vi.fn();
+    render(
+      <ConversationList
+        conversations={conversations}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={onSearchTermChange}
+        now={NOW}
+      />
+    );
+    fireEvent.change(screen.getByRole('textbox', { name: 'Search conversations' }), { target: { value: 'query' } });
+    expect(onSearchTermChange).toHaveBeenCalledWith('query');
+  });
+
+  it('marks the selected conversation', () => {
+    render(
+      <ConversationList
+        conversations={conversations}
+        selectedId="a"
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    expect(screen.getByRole('button', { name: /^Explain revenue by region/ }).getAttribute('aria-current')).toBe(
+      'true'
+    );
+  });
+
+  it('reports the selected conversation id', () => {
+    const onSelect = vi.fn();
+    render(
+      <ConversationList
+        conversations={conversations}
+        onSelect={onSelect}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    fireEvent.click(screen.getByRole('button', { name: /^Explain revenue by region/ }));
+    expect(onSelect).toHaveBeenCalledWith('a');
+  });
+
+  it('reports the deleted conversation id without asking for confirmation', () => {
+    const onDelete = vi.fn();
+    render(
+      <ConversationList
+        conversations={conversations}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={onDelete}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    fireEvent.click(screen.getByRole('button', { name: 'Delete Explain revenue by region' }));
+    expect(onDelete).toHaveBeenCalledWith('a');
+  });
+
+  it('offers no delete button for a read-only conversation', () => {
+    render(
+      <ConversationList
+        conversations={conversations}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    expect(screen.queryByRole('button', { name: 'Delete Shared analysis' })).toBeNull();
+  });
+
+  it('reports a new-conversation request', () => {
+    const onCreate = vi.fn();
+    render(
+      <ConversationList
+        conversations={conversations}
+        onSelect={noop}
+        onCreate={onCreate}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    fireEvent.click(screen.getByRole('button', { name: 'New conversation' }));
+    expect(onCreate).toHaveBeenCalled();
+  });
+
+  it('splits today from earlier conversations, each under its own section', () => {
+    render(
+      <ConversationList
+        conversations={conversations}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    expect(screen.getByText('Today')).toBeTruthy();
+    expect(screen.getByText('Earlier')).toBeTruthy();
+    const [todayList, earlierList] = screen.getAllByRole('list');
+    expect(within(todayList).getByText('Explain revenue by region')).toBeTruthy();
+    expect(within(earlierList).getByText('Draft a query')).toBeTruthy();
+    expect(within(earlierList).getByText('Shared analysis')).toBeTruthy();
+  });
+
+  it('splits at local midnight', () => {
+    render(
+      <ConversationList
+        conversations={[
+          { id: 'x', title: 'After midnight', updatedAt: new Date(2026, 9, 15, 0, 1).toISOString() },
+          { id: 'y', title: 'Before midnight', updatedAt: new Date(2026, 9, 14, 23, 59).toISOString() }
+        ]}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    const [todayList, earlierList] = screen.getAllByRole('list');
+    expect(within(todayList).getByText('After midnight')).toBeTruthy();
+    expect(within(earlierList).getByText('Before midnight')).toBeTruthy();
+  });
+
+  it('hides a section heading when that section is empty', () => {
+    render(
+      <ConversationList
+        conversations={[conversations[0]]}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    expect(screen.getByText('Today')).toBeTruthy();
+    expect(screen.queryByText('Earlier')).toBeNull();
+  });
+
+  it('shows a relative time for a today conversation and none for an earlier one', () => {
+    render(
+      <ConversationList
+        conversations={conversations}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    expect(screen.getByText('1 hr. ago')).toBeTruthy();
+    expect(screen.getByRole('button', { name: 'Draft a query' }).querySelector('time')).toBeNull();
+  });
+
+  it('drops section headings while searching', () => {
+    render(
+      <ConversationList
+        conversations={conversations}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    fireEvent.change(screen.getByRole('textbox', { name: 'Search conversations' }), { target: { value: 'a' } });
+    expect(screen.queryByText('Today')).toBeNull();
+    expect(screen.queryByText('Earlier')).toBeNull();
+  });
+
+  it('does not match by date, only by title', () => {
+    render(
+      <ConversationList
+        conversations={conversations}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    fireEvent.change(screen.getByRole('textbox', { name: 'Search conversations' }), {
+      target: { value: '2026-10-10' }
+    });
+    expect(screen.getByRole('status').textContent).toBe('No conversations match your search.');
+  });
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.stories.tsx
new file mode 100644
index 0000000000..cdfda369a2
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.stories.tsx
@@ -0,0 +1,69 @@
+/*
+ * 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 { expect, fn, userEvent, within } from 'storybook/test';
+import type { AssistantConversation } from '../model/assistantContract';
+import { ConversationList } from './ConversationList';
+
+const NOW = new Date(2026, 9, 15, 12).getTime();
+
+const conversations: AssistantConversation[] = [
+  { id: 'a', title: 'Explain revenue by region', updatedAt: new Date(2026, 9, 15, 11).toISOString() },
+  { id: 'b', title: 'Draft a query for monthly orders', updatedAt: new Date(2026, 9, 10, 9).toISOString() },
+  {
+    id: 'c',
+    title: 'Review shared analysis',
+    canSendMessage: false,
+    updatedAt: new Date(2026, 9, 9, 9).toISOString()
+  }
+];
+
+const meta = {
+  title: 'Assistant/ConversationList',
+  component: ConversationList,
+  args: {
+    conversations,
+    onSelect: fn(),
+    onCreate: fn(),
+    onDelete: fn(),
+    onSearchTermChange: fn(),
+    now: NOW
+  }
+} satisfies Meta<typeof ConversationList>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const TodayAndEarlier: Story = {};
+
+export const Empty: Story = { args: { conversations: [] } };
+
+export const Search: Story = {
+  play: async ({ canvasElement, args }) => {
+    const canvas = within(canvasElement);
+    await userEvent.type(canvas.getByRole('textbox', { name: 'Search conversations' }), 'query');
+    await expect(args.onSearchTermChange).toHaveBeenCalledWith('query');
+    await expect(canvas.getByText('Draft a query for monthly orders')).toBeInTheDocument();
+    await expect(canvas.queryByText('Explain revenue by region')).toBeNull();
+  }
+};
+
+export const SelectAndDelete: Story = {
+  play: async ({ canvasElement, args }) => {
+    const canvas = within(canvasElement);
+    await userEvent.click(canvas.getByRole('button', { name: /^Explain revenue by region/ }));
+    await expect(args.onSelect).toHaveBeenCalledWith('a');
+    await userEvent.click(canvas.getByRole('button', { name: 'Delete Explain revenue by region' }));
+    await expect(args.onDelete).toHaveBeenCalledWith('a');
+  }
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.tsx
new file mode 100644
index 0000000000..d2ed38a1ab
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.tsx
@@ -0,0 +1,172 @@
+/*
+ * 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 * as styles from './ConversationList.css';
+
+import { useState } from 'react';
+import { DeleteOutlined, LockOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
+import { Button, Input } from 'antd';
+import { isSameDay } from 'date-fns';
+import type { AssistantConversation } from '../model/assistantContract';
+
+const MINUTE = 60_000;
+const relativeTime = new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'short' });
+
+const formatRelative = (iso: string, now: number): string => {
+  const minutes = Math.round((Date.parse(iso) - now) / MINUTE);
+  if (minutes > -1) return 'now';
+  if (minutes > -60) return relativeTime.format(minutes, 'minute');
+  return relativeTime.format(Math.round(minutes / 60), 'hour');
+};
+
+const matchesSearch = (conversation: AssistantConversation, needle: string) =>
+  (conversation.title ?? '').toLowerCase().includes(needle);
+
+export interface ConversationListProps {
+  conversations: AssistantConversation[];
+  selectedId?: string;
+  onSelect: (conversationId: string) => void;
+  onCreate: () => void;
+  onDelete: (conversationId: string) => void;
+  onSearchTermChange: (term: string) => void;
+  now?: number;
+}
+
+const ConversationRow = ({
+  conversation,
+  selected,
+  showTime,
+  now,
+  onSelect,
+  onDelete
+}: {
+  conversation: AssistantConversation;
+  selected: boolean;
+  showTime: boolean;
+  now: number;
+  onSelect: (id: string) => void;
+  onDelete: (id: string) => void;
+}) => {
+  const title = conversation.title;
+  const readOnly = conversation.canSendMessage === false;
+  return (
+    <li className={styles.row}>
+      <button
+        type="button"
+        className={styles.rowButton}
+        aria-current={selected}
+        onClick={() => onSelect(conversation.id)}
+      >
+        <span className={styles.rowTitle}>{title}</span>
+        {readOnly || showTime ? (
+          <span className={styles.rowMeta}>
+            {readOnly ? (
+              <>
+                <LockOutlined aria-hidden="true" />
+                <span className={styles.visuallyHidden}>Read-only</span>
+              </>
+            ) : null}
+            {showTime && conversation.updatedAt ? (
+              <>
+                <span className={styles.visuallyHidden}>, </span>
+                <time dateTime={conversation.updatedAt}>{formatRelative(conversation.updatedAt, now)}</time>
+              </>
+            ) : null}
+          </span>
+        ) : null}
+      </button>
+      {!readOnly && (
+        <Button
+          size="small"
+          type="text"
+          icon={<DeleteOutlined />}
+          aria-label={`Delete ${title}`}
+          onClick={() => onDelete(conversation.id)}
+        />
+      )}
+    </li>
+  );
+};
+
+export const ConversationList = ({
+  conversations,
+  selectedId,
+  onSelect,
+  onCreate,
+  onDelete,
+  onSearchTermChange,
+  now = Date.now()
+}: ConversationListProps) => {
+  const [query, setQuery] = useState('');
+  const needle = query.trim().toLowerCase();
+  const searching = needle.length > 0;
+  const shown = searching ? conversations.filter(conversation => matchesSearch(conversation, needle)) : conversations;
+  const today = shown.filter(conversation => conversation.updatedAt && isSameDay(conversation.updatedAt, now));
+  const earlier = shown.filter(conversation => !today.includes(conversation));
+
+  const handleSearchChange = (value: string) => {
+    setQuery(value);
+    onSearchTermChange(value);
+  };
+
+  const row = (conversation: AssistantConversation, showTime: boolean) => (
+    <ConversationRow
+      key={conversation.id}
+      conversation={conversation}
+      selected={conversation.id === selectedId}
+      showTime={showTime}
+      now={now}
+      onSelect={onSelect}
+      onDelete={onDelete}
+    />
+  );
+
+  return (
+    <section aria-label="Conversations" className={styles.list}>
+      <div className={styles.header}>
+        <h3 className={styles.title}>Conversations</h3>
+        <Button size="small" type="text" icon={<PlusOutlined />} aria-label="New conversation" onClick={onCreate} />
+      </div>
+      <div className={styles.search}>
+        <Input
+          allowClear
+          prefix={<SearchOutlined aria-hidden="true" />}
+          placeholder="Search conversations"
+          aria-label="Search conversations"
+          value={query}
+          onChange={event => handleSearchChange(event.target.value)}
+        />
+      </div>
+      {shown.length === 0 ? (
+        <p className={styles.empty} role="status">
+          {conversations.length === 0 ? 'No conversations yet.' : 'No conversations match your search.'}
+        </p>
+      ) : (
+        <>
+          {!searching && today.length > 0 && (
+            <>
+              <div className={styles.sectionLabel}>Today</div>
+              <ul className={styles.items}>{today.map(conversation => row(conversation, true))}</ul>
+            </>
+          )}
+          {!searching && earlier.length > 0 && (
+            <>
+              <div className={styles.sectionLabel}>Earlier</div>
+              <ul className={styles.items}>{earlier.map(conversation => row(conversation, false))}</ul>
+            </>
+          )}
+          {searching && <ul className={styles.items}>{shown.map(conversation => row(conversation, false))}</ul>}
+        </>
+      )}
+    </section>
+  );
+};


(zeppelin) 08/17: Merge #5563 Assistant transport

Posted by yo...@apache.org.
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 5639220dd561ebecd73703b2f8c52bf087fd0f0d
Merge: 796c8c094f 98a738de0c
Author: YONGJAE LEE <de...@gmail.com>
AuthorDate: Sat Oct 17 09:20:54 2026 +0900

    Merge #5563 Assistant transport

 .../entities/assistant/model/assistantContract.ts  |   4 -
 .../assistant/model/assistantTransport.spec.ts     | 713 +++++++++++++++++++++
 .../entities/assistant/model/assistantTransport.ts | 429 +++++++++++++
 .../projects/zeppelin-sdk/src/assistant.ts         |  27 +
 .../projects/zeppelin-sdk/src/public-api.ts        |   1 +
 5 files changed, 1170 insertions(+), 4 deletions(-)


(zeppelin) 16/17: Merge #5569 rebased onto master

Posted by yo...@apache.org.
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 9e8ee471fac3b97af370d47e11df447ecb5e957b
Merge: d4372cdd18 c9a0944a24
Author: YONGJAE LEE <de...@gmail.com>
AuthorDate: Sat Oct 17 09:21:35 2026 +0900

    Merge #5569 rebased onto master

 .../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(+)


(zeppelin) 11/17: [ZEPPELIN-6745] Align answer UI with segmented history

Posted by yo...@apache.org.
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 9fd079b214112ffe4dc13897da445ebac18049ef
Author: YONGJAE LEE <de...@gmail.com>
AuthorDate: Fri Oct 16 12:42:19 2026 +0900

    [ZEPPELIN-6745] Align answer UI with segmented history
---
 .../entities/assistant/ui/AssistantAnswer.spec.tsx | 53 ++++++++++++++++++++--
 .../assistant/ui/AssistantAnswer.stories.tsx       | 19 +++++---
 .../src/entities/assistant/ui/AssistantAnswer.tsx  | 13 ++++--
 .../assistant/ui/AssistantMessageItem.stories.tsx  |  6 ++-
 .../src/entities/assistant/ui/AssistantToolLog.tsx |  9 +++-
 .../src/entities/assistant/ui/answerView.ts        |  6 +--
 6 files changed, 85 insertions(+), 21 deletions(-)

diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx
index 7531cf429e..e1a3ed4253 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx
@@ -42,8 +42,10 @@ describe('AssistantAnswer', () => {
           message: {
             id: 'm1',
             role: 'assistant',
-            content: 'Revenue is grouped by region.',
-            toolCalls: [{ id: 't1', name: 'list_paragraphs' }]
+            segments: [
+              { id: 's1', content: '', toolCalls: [{ id: 't1', name: 'list_paragraphs' }] },
+              { id: 's2', content: 'Revenue is grouped by region.' }
+            ]
           }
         }}
       />
@@ -97,8 +99,7 @@ describe('AssistantAnswer', () => {
           message: {
             id: 'm1',
             role: 'assistant',
-            content: 'Revenue',
-            toolCalls: [{ id: 't1', name: 'list_paragraphs' }]
+            segments: [{ id: 's1', content: 'Revenue', toolCalls: [{ id: 't1', name: 'list_paragraphs' }] }]
           }
         }}
       />
@@ -111,6 +112,28 @@ describe('AssistantAnswer', () => {
     expect(screen.getByRole('status').textContent).toBe('Running Tool…');
   });
 
+  it('keeps completed answer rounds in order and shows tools from every round', () => {
+    render(
+      <AssistantAnswer
+        view={{
+          status: 'done',
+          message: {
+            id: 'm2',
+            role: 'assistant',
+            segments: [
+              { id: 'm1', content: 'First finding', toolCalls: [{ id: 't1', name: 'list_paragraphs' }] },
+              { id: 'm2', content: 'Second finding', toolCalls: [{ id: 't2', name: 'read_results' }] }
+            ]
+          }
+        }}
+      />
+    );
+
+    expect(answer().textContent).toContain('First finding\n\nSecond finding');
+    expect(screen.getByText('Used 2 tools')).toBeTruthy();
+    expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['list_paragraphs', 'read_results']);
+  });
+
   it('keeps the partial answer and replaces the progress line with the error', () => {
     const view: AssistantAnswerView = {
       status: 'error',
@@ -125,10 +148,30 @@ describe('AssistantAnswer', () => {
     expect(screen.getByRole('alert').textContent).toContain('stopped responding');
   });
 
+  it('does not imply a tool is still observed after the connection is lost', () => {
+    render(
+      <AssistantAnswer
+        view={{
+          status: 'error',
+          kind: 'connection',
+          message: 'Connection lost',
+          tools: [{ id: 't1', name: 'read_results', status: 'running' }]
+        }}
+      />
+    );
+
+    expect(screen.getByText('Used 1 tool (status unknown)')).toBeTruthy();
+    expect(screen.queryByRole('img', { name: 'Running' })).toBeNull();
+    expect(screen.getByRole('img', { name: 'Status unknown' })).toBeTruthy();
+  });
+
   it('renders answer bodies through the given renderer', () => {
     render(
       <AssistantAnswer
-        view={{ status: 'done', message: { id: 'm1', role: 'assistant', content: '**bold**' } }}
+        view={{
+          status: 'done',
+          message: { id: 'm1', role: 'assistant', segments: [{ id: 's1', content: '**bold**' }] }
+        }}
         renderContent={content => <strong>{content.replaceAll('*', '')}</strong>}
       />
     );
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx
index cedeff5d6e..dc472e2696 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx
@@ -30,16 +30,22 @@ const fixtures = {
       { id: 't2', name: 'list_paragraphs', status: 'running' }
     ]
   },
-  completed: { status: 'done', message: { id: 'a1', role: 'assistant', content: reply } },
+  completed: { status: 'done', message: { id: 'a1', role: 'assistant', segments: [{ id: 's1', content: reply }] } },
   completedWithTools: {
     status: 'done',
     message: {
       id: 'a1',
       role: 'assistant',
-      content: reply,
-      toolCalls: [
-        { id: 't1', name: 'list_paragraphs' },
-        { id: 't2', name: 'list_paragraphs' }
+      segments: [
+        {
+          id: 's1',
+          content: '',
+          toolCalls: [
+            { id: 't1', name: 'list_paragraphs' },
+            { id: 't2', name: 'list_paragraphs' }
+          ]
+        },
+        { id: 's2', content: reply }
       ]
     }
   },
@@ -55,7 +61,8 @@ const fixtures = {
     status: 'error',
     kind: 'connection',
     message: new AssistantConnectionError().message,
-    content: partial
+    content: partial,
+    tools: [{ id: 't1', name: 'read_results', status: 'running' }]
   }
 } satisfies Record<string, AssistantAnswerView>;
 
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx
index b0f6fbecbc..1139c73b2e 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx
@@ -11,6 +11,7 @@
  */
 
 import { Skeleton } from 'antd';
+import { answerText, answerToolCalls } from '../model/messageHistory';
 import type { AssistantContentRenderer, AssistantAnswerView, AssistantToolActivity } from './answerView';
 import { AssistantAnswerError } from './AssistantAnswerError';
 import { AssistantMessageItem } from './AssistantMessageItem';
@@ -61,19 +62,23 @@ export const AssistantAnswer = ({ view, onRetry, renderContent }: AssistantAnswe
       );
     }
     case 'done': {
-      const tools = (view.message.toolCalls ?? []).map(call => ({ ...call, status: 'done' as const }));
+      const tools = answerToolCalls(view.message).map(call => ({ ...call, status: 'done' as const }));
       return (
-        <AssistantMessageItem role="assistant" content={view.message.content} renderContent={renderContent}>
+        <AssistantMessageItem role="assistant" content={answerText(view.message)} renderContent={renderContent}>
           <AssistantToolLog tools={tools} />
         </AssistantMessageItem>
       );
     }
-    case 'error':
+    case 'error': {
+      const tools = (view.tools ?? []).map(tool =>
+        tool.status === 'running' ? { ...tool, status: 'unknown' as const } : tool
+      );
       return (
         <AssistantMessageItem role="assistant" content={view.content ?? ''} renderContent={renderContent}>
-          <AssistantToolLog tools={view.tools ?? []} />
+          <AssistantToolLog tools={tools} />
           <AssistantAnswerError kind={view.kind} message={view.message} onRetry={onRetry} />
         </AssistantMessageItem>
       );
+    }
   }
 };
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx
index 75931eb2b7..69ba1ccff4 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx
@@ -54,7 +54,11 @@ export const Conversation: Story = {
       <AssistantAnswer
         view={{
           status: 'done',
-          message: { id: 'a1', role: 'assistant', content: reply, toolCalls: [{ id: 't1', name: 'list_paragraphs' }] }
+          message: {
+            id: 'a1',
+            role: 'assistant',
+            segments: [{ id: 's1', content: reply, toolCalls: [{ id: 't1', name: 'list_paragraphs' }] }]
+          }
         }}
       />
     </div>
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx
index 46a0a111a6..aa1359733a 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx
@@ -10,7 +10,7 @@
  * limitations under the License.
  */
 
-import { CheckOutlined } from '@ant-design/icons';
+import { CheckOutlined, QuestionCircleOutlined } from '@ant-design/icons';
 import { Spin } from 'antd';
 import type { AssistantToolActivity } from './answerView';
 import * as styles from './AssistantAnswer.css';
@@ -25,10 +25,13 @@ export interface AssistantToolLogProps {
 export const AssistantToolLog = ({ tools }: AssistantToolLogProps) => {
   if (!tools.length) return null;
   const running = tools.some(tool => tool.status === 'running');
+  const unknown = tools.some(tool => tool.status === 'unknown');
   const count = `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'}`;
   return (
     <details className={styles.toolLog} open={running}>
-      <summary className={styles.toolSummary}>{running ? `Using ${count}` : `Used ${count}`}</summary>
+      <summary className={styles.toolSummary}>
+        {running ? `Using ${count}` : unknown ? `Used ${count} (status unknown)` : `Used ${count}`}
+      </summary>
       <ul className={styles.toolList}>
         {tools.map(tool => (
           <li key={tool.id} className={styles.toolItem} data-status={tool.status}>
@@ -36,6 +39,8 @@ export const AssistantToolLog = ({ tools }: AssistantToolLogProps) => {
               <span className={styles.toolIcon} role="img" aria-label="Running">
                 <Spin size="small" />
               </span>
+            ) : tool.status === 'unknown' ? (
+              <QuestionCircleOutlined className={styles.toolIcon} aria-label="Status unknown" />
             ) : (
               <CheckOutlined className={`${styles.toolIcon} ${styles.toolDone}`} aria-label="Done" />
             )}
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/answerView.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/answerView.ts
index b11e7ebcfc..f154658c79 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/answerView.ts
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/answerView.ts
@@ -11,13 +11,13 @@
  */
 
 import type { ReactNode } from 'react';
-import type { AssistantMessage } from '../model/assistantContract';
+import type { AssistantAnswerMessage } from '../model/assistantContract';
 
 /** A tool the assistant called during a live run. `tool_call.done` may omit the name, so it is optional. */
 export interface AssistantToolActivity {
   id: string;
   name?: string;
-  status: 'running' | 'done';
+  status: 'running' | 'done' | 'unknown';
 }
 
 /**
@@ -30,7 +30,7 @@ export type AssistantAnswerErrorKind = 'failed' | 'stream' | 'connection';
 export type AssistantAnswerView =
   | { status: 'loading' }
   | { status: 'streaming'; content: string; tools?: AssistantToolActivity[] }
-  | { status: 'done'; message: AssistantMessage }
+  | { status: 'done'; message: AssistantAnswerMessage }
   | {
       status: 'error';
       kind: AssistantAnswerErrorKind;


(zeppelin) 04/17: [ZEPPELIN-6746] Add AI Assistant composer stories

Posted by yo...@apache.org.
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 5783b38d0f6949b3410ceae5e443d5cc23a8e6d4
Author: kimyenac <ye...@gmail.com>
AuthorDate: Tue Oct 13 16:24:11 2026 +0900

    [ZEPPELIN-6746] Add AI Assistant composer stories
---
 .../assistant/ui/AssistantComposer.stories.tsx     | 64 ++++++++++++++++++++++
 1 file changed, 64 insertions(+)

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


(zeppelin) 15/17: Merge #5567 rebased onto master

Posted by yo...@apache.org.
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 d4372cdd18e4f2ae1e8c15e5bea9cf35a91c6b23
Merge: 1dc27a7c78 9fd079b214
Author: YONGJAE LEE <de...@gmail.com>
AuthorDate: Sat Oct 17 09:21:35 2026 +0900

    Merge #5567 rebased onto master

 .../entities/assistant/ui/AssistantAnswer.css.ts   |  91 +++++++++++
 .../entities/assistant/ui/AssistantAnswer.spec.tsx | 180 +++++++++++++++++++++
 .../assistant/ui/AssistantAnswer.stories.tsx       |  95 +++++++++++
 .../src/entities/assistant/ui/AssistantAnswer.tsx  |  84 ++++++++++
 .../assistant/ui/AssistantAnswerError.spec.tsx     |  49 ++++++
 .../entities/assistant/ui/AssistantAnswerError.tsx |  46 ++++++
 .../assistant/ui/AssistantMessageItem.css.ts       |  85 ++++++++++
 .../assistant/ui/AssistantMessageItem.spec.tsx     |  35 ++++
 .../assistant/ui/AssistantMessageItem.stories.tsx  |  66 ++++++++
 .../entities/assistant/ui/AssistantMessageItem.tsx |  67 ++++++++
 .../src/entities/assistant/ui/AssistantToolLog.tsx |  53 ++++++
 .../src/entities/assistant/ui/answerView.ts        |  44 +++++
 .../ui/assistant-theme/AssistantScope.stories.tsx  |  35 ----
 13 files changed, 895 insertions(+), 35 deletions(-)


(zeppelin) 17/17: Merge #5575 rebased onto master

Posted by yo...@apache.org.
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 d4702bde1d93ded0f4048b7bb42957f7971e2283
Merge: 9e8ee471fa bfbb0abb5e
Author: YONGJAE LEE <de...@gmail.com>
AuthorDate: Sat Oct 17 09:21:35 2026 +0900

    Merge #5575 rebased onto master

 .../assistant/ui/ConversationHeader.css.ts         |  35 +++
 .../assistant/ui/ConversationHeader.spec.tsx       |  70 +++++
 .../assistant/ui/ConversationHeader.stories.tsx    |  44 +++
 .../entities/assistant/ui/ConversationHeader.tsx   |  50 ++++
 .../entities/assistant/ui/ConversationList.css.ts  | 106 +++++++
 .../assistant/ui/ConversationList.spec.tsx         | 303 +++++++++++++++++++++
 .../assistant/ui/ConversationList.stories.tsx      |  82 ++++++
 .../src/entities/assistant/ui/ConversationList.tsx | 172 ++++++++++++
 8 files changed, 862 insertions(+)


(zeppelin) 01/17: [ZEPPELIN-6745] Add AI Assistant message item and answer view types

Posted by yo...@apache.org.
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 4fcdd3ddbae5e9c4b20faab82f1b295acf28d930
Author: kimyenac <ye...@gmail.com>
AuthorDate: Mon Oct 12 12:53:29 2026 +0900

    [ZEPPELIN-6745] Add AI Assistant message item and answer view types
---
 .../assistant/ui/AssistantMessageItem.css.ts       | 85 ++++++++++++++++++++++
 .../assistant/ui/AssistantMessageItem.spec.tsx     | 35 +++++++++
 .../entities/assistant/ui/AssistantMessageItem.tsx | 67 +++++++++++++++++
 .../src/entities/assistant/ui/answerView.ts        | 44 +++++++++++
 4 files changed, 231 insertions(+)

diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.css.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.css.ts
new file mode 100644
index 0000000000..3998cf6a3b
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.css.ts
@@ -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 { keyframes, style } from '@vanilla-extract/css';
+import { enter, vars } from '@/shared/ui/assistant-theme/AssistantScope.css';
+
+export const message = style({
+  display: 'flex',
+  flexDirection: 'column',
+  gap: vars.space1,
+  minWidth: 0,
+  animation: `${enter} ${vars.durationModerate} ${vars.easeEntrance}`
+});
+
+export const user = style({
+  alignSelf: 'flex-end',
+  alignItems: 'flex-end',
+  maxWidth: '85%'
+});
+
+export const assistant = style({
+  alignSelf: 'stretch'
+});
+
+export const label = style({
+  display: 'flex',
+  alignItems: 'center',
+  gap: vars.space2,
+  color: vars.textSecondary,
+  fontSize: vars.fontSizeSm
+});
+
+export const badge = style({
+  display: 'inline-flex',
+  alignItems: 'center',
+  justifyContent: 'center',
+  width: 20,
+  height: 20,
+  borderRadius: vars.radiusSm,
+  background: vars.accent,
+  color: vars.bg,
+  fontSize: vars.fontSizeXs,
+  fontWeight: 600
+});
+
+export const bubble = style({
+  padding: `${vars.space2} ${vars.space3}`,
+  borderRadius: vars.radiusLg,
+  border: `1px solid ${vars.accentBorder}`,
+  background: vars.accentBg,
+  overflowWrap: 'anywhere'
+});
+
+export const body = style({
+  minWidth: 0,
+  overflowWrap: 'anywhere'
+});
+
+export const plainText = style({
+  whiteSpace: 'pre-wrap'
+});
+
+const blink = keyframes({
+  '50%': { opacity: 0 }
+});
+
+/** Trails the partial answer while it streams. */
+export const caret = style({
+  display: 'inline-block',
+  width: 2,
+  height: '1em',
+  marginLeft: 2,
+  verticalAlign: 'text-bottom',
+  background: vars.accent,
+  animation: `${blink} 1s steps(1) infinite`
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.spec.tsx
new file mode 100644
index 0000000000..0b280b4277
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.spec.tsx
@@ -0,0 +1,35 @@
+/*
+ * 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 { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { AssistantMessageItem } from './AssistantMessageItem';
+
+describe('AssistantMessageItem', () => {
+  it('tells a question from an answer', () => {
+    render(
+      <>
+        <AssistantMessageItem role="user" content="How is revenue calculated?" />
+        <AssistantMessageItem role="assistant" content="By region." />
+      </>
+    );
+    expect(screen.getByRole('article', { name: 'Your question' }).textContent).toBe('YouHow is revenue calculated?');
+    expect(screen.getByRole('article', { name: 'Assistant answer' }).textContent).toContain('By region.');
+  });
+
+  it('shows a question as typed instead of rendering it', () => {
+    render(<AssistantMessageItem role="user" content="<b>not bold</b>" renderContent={() => 'rendered'} />);
+    const question = screen.getByRole('article', { name: 'Your question' });
+    expect(question.textContent).toContain('<b>not bold</b>');
+    expect(question.querySelector('b')).toBeNull();
+  });
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.tsx
new file mode 100644
index 0000000000..e7c1e4913e
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.tsx
@@ -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 type { ReactNode } from 'react';
+import type { AssistantMessage } from '../model/assistantContract';
+import type { AssistantContentRenderer } from './answerView';
+import * as styles from './AssistantMessageItem.css';
+
+const renderPlainText: AssistantContentRenderer = content => <div className={styles.plainText}>{content}</div>;
+
+export interface AssistantMessageItemProps {
+  role: AssistantMessage['role'];
+  content: string;
+  /** The answer is still arriving: marks it busy and shows a caret after the text. */
+  streaming?: boolean;
+  renderContent?: AssistantContentRenderer;
+  /** Shown under an assistant answer, such as the tools it used or an error. */
+  children?: ReactNode;
+}
+
+export const AssistantMessageItem = ({
+  role,
+  content,
+  streaming = false,
+  renderContent = renderPlainText,
+  children
+}: AssistantMessageItemProps) => {
+  if (role === 'user') {
+    return (
+      <article className={`${styles.message} ${styles.user}`} data-role="user" aria-label="Your question">
+        <div className={styles.label}>You</div>
+        <div className={`${styles.bubble} ${styles.plainText}`}>{content}</div>
+      </article>
+    );
+  }
+  return (
+    <article
+      className={`${styles.message} ${styles.assistant}`}
+      data-role="assistant"
+      aria-label="Assistant answer"
+      aria-busy={streaming || undefined}
+    >
+      <header className={styles.label}>
+        <span className={styles.badge} aria-hidden="true">
+          AI
+        </span>
+        <span>Assistant</span>
+      </header>
+      {content && (
+        <div className={styles.body}>
+          {renderContent(content)}
+          {streaming && <span className={styles.caret} aria-hidden="true" />}
+        </div>
+      )}
+      {children}
+    </article>
+  );
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/answerView.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/answerView.ts
new file mode 100644
index 0000000000..b11e7ebcfc
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/answerView.ts
@@ -0,0 +1,44 @@
+/*
+ * 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 { ReactNode } from 'react';
+import type { AssistantMessage } from '../model/assistantContract';
+
+/** A tool the assistant called during a live run. `tool_call.done` may omit the name, so it is optional. */
+export interface AssistantToolActivity {
+  id: string;
+  name?: string;
+  status: 'running' | 'done';
+}
+
+/**
+ * Why an answer stopped: the server reported `run.failed`, the stream ended early (`AssistantStreamError`),
+ * or the connection dropped and only a reload shows where the answer got to (`AssistantConnectionError`).
+ */
+export type AssistantAnswerErrorKind = 'failed' | 'stream' | 'connection';
+
+/** What one answer shows. The panel derives it from run events; the components only display it. */
+export type AssistantAnswerView =
+  | { status: 'loading' }
+  | { status: 'streaming'; content: string; tools?: AssistantToolActivity[] }
+  | { status: 'done'; message: AssistantMessage }
+  | {
+      status: 'error';
+      kind: AssistantAnswerErrorKind;
+      message: string;
+      /** The part of the answer that arrived before it stopped. */
+      content?: string;
+      tools?: AssistantToolActivity[];
+    };
+
+/** Renders an answer body. Plain text until the Markdown answer component is wired in. */
+export type AssistantContentRenderer = (content: string) => ReactNode;


(zeppelin) 14/17: [ZEPPELIN-6747] Fill the Assistant conversation fixtures from the master contract

Posted by yo...@apache.org.
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 bfbb0abb5ee81a1f95fe64c015369affac16e515
Author: YONGJAE LEE <de...@gmail.com>
AuthorDate: Sat Oct 17 09:21:34 2026 +0900

    [ZEPPELIN-6747] Fill the Assistant conversation fixtures from the master contract
---
 .../assistant/ui/ConversationHeader.spec.tsx       | 24 +++++++++++--
 .../assistant/ui/ConversationHeader.stories.tsx    | 16 +++++++--
 .../assistant/ui/ConversationList.spec.tsx         | 40 +++++++++++++++++++---
 .../assistant/ui/ConversationList.stories.tsx      | 17 +++++++--
 4 files changed, 85 insertions(+), 12 deletions(-)

diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.spec.tsx
index bda08d1065..3ade34aa79 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.spec.tsx
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.spec.tsx
@@ -19,7 +19,13 @@ const noop = () => {};
 
 describe('ConversationHeader', () => {
   it('shows the selected conversation title', () => {
-    const conversation: AssistantConversation = { id: 'a', title: 'Explain revenue by region' };
+    const conversation: AssistantConversation = {
+      id: 'a',
+      ownerId: 'user1',
+      title: 'Explain revenue by region',
+      canSendMessage: true,
+      updatedAt: '2026-10-15T02:00:00.000Z'
+    };
     render(<ConversationHeader conversation={conversation} onCreate={noop} onDelete={noop} />);
     expect(screen.getByText('Explain revenue by region')).toBeTruthy();
   });
@@ -30,14 +36,26 @@ describe('ConversationHeader', () => {
   });
 
   it('offers no delete button for a read-only conversation', () => {
-    const conversation: AssistantConversation = { id: 'a', title: 'Shared', canSendMessage: false };
+    const conversation: AssistantConversation = {
+      id: 'a',
+      ownerId: 'user1',
+      title: 'Shared',
+      canSendMessage: false,
+      updatedAt: '2026-10-15T02:00:00.000Z'
+    };
     render(<ConversationHeader conversation={conversation} onCreate={noop} onDelete={noop} />);
     expect(screen.queryByRole('button', { name: 'Delete Shared' })).toBeNull();
   });
 
   it('reports the deleted conversation id without asking for confirmation', () => {
     const onDelete = vi.fn();
-    const conversation: AssistantConversation = { id: 'a', title: 'Explain revenue by region' };
+    const conversation: AssistantConversation = {
+      id: 'a',
+      ownerId: 'user1',
+      title: 'Explain revenue by region',
+      canSendMessage: true,
+      updatedAt: '2026-10-15T02:00:00.000Z'
+    };
     render(<ConversationHeader conversation={conversation} onCreate={noop} onDelete={onDelete} />);
     fireEvent.click(screen.getByRole('button', { name: 'Delete Explain revenue by region' }));
     expect(onDelete).toHaveBeenCalledWith('a');
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.stories.tsx
index 83e3de509d..d469e3672d 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.stories.tsx
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.stories.tsx
@@ -15,8 +15,20 @@ import { fn } from 'storybook/test';
 import type { AssistantConversation } from '../model/assistantContract';
 import { ConversationHeader } from './ConversationHeader';
 
-const current: AssistantConversation = { id: 'a', title: 'Explain how the notebook calculates revenue' };
-const readOnly: AssistantConversation = { id: 'b', title: 'Compare regional totals', canSendMessage: false };
+const current: AssistantConversation = {
+  id: 'a',
+  ownerId: 'user1',
+  title: 'Explain how the notebook calculates revenue',
+  canSendMessage: true,
+  updatedAt: '2026-10-15T02:00:00.000Z'
+};
+const readOnly: AssistantConversation = {
+  id: 'b',
+  ownerId: 'user1',
+  title: 'Compare regional totals',
+  canSendMessage: false,
+  updatedAt: '2026-10-15T02:00:00.000Z'
+};
 
 const meta = {
   title: 'Assistant/ConversationHeader',
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.spec.tsx
index 6909e74eb1..2e9a3ca77e 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.spec.tsx
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.spec.tsx
@@ -18,9 +18,27 @@ import { ConversationList } from './ConversationList';
 const NOW = new Date(2026, 9, 15, 12).getTime();
 
 const conversations: AssistantConversation[] = [
-  { id: 'a', title: 'Explain revenue by region', updatedAt: new Date(2026, 9, 15, 11).toISOString() },
-  { id: 'b', title: 'Draft a query', updatedAt: new Date(2026, 9, 10, 9).toISOString() },
-  { id: 'c', title: 'Shared analysis', canSendMessage: false, updatedAt: new Date(2026, 9, 9, 9).toISOString() }
+  {
+    id: 'a',
+    ownerId: 'user1',
+    title: 'Explain revenue by region',
+    updatedAt: new Date(2026, 9, 15, 11).toISOString(),
+    canSendMessage: true
+  },
+  {
+    id: 'b',
+    ownerId: 'user1',
+    title: 'Draft a query',
+    updatedAt: new Date(2026, 9, 10, 9).toISOString(),
+    canSendMessage: true
+  },
+  {
+    id: 'c',
+    ownerId: 'user1',
+    title: 'Shared analysis',
+    canSendMessage: false,
+    updatedAt: new Date(2026, 9, 9, 9).toISOString()
+  }
 ];
 
 const noop = () => {};
@@ -193,8 +211,20 @@ describe('ConversationList', () => {
     render(
       <ConversationList
         conversations={[
-          { id: 'x', title: 'After midnight', updatedAt: new Date(2026, 9, 15, 0, 1).toISOString() },
-          { id: 'y', title: 'Before midnight', updatedAt: new Date(2026, 9, 14, 23, 59).toISOString() }
+          {
+            id: 'x',
+            ownerId: 'user1',
+            title: 'After midnight',
+            updatedAt: new Date(2026, 9, 15, 0, 1).toISOString(),
+            canSendMessage: true
+          },
+          {
+            id: 'y',
+            ownerId: 'user1',
+            title: 'Before midnight',
+            updatedAt: new Date(2026, 9, 14, 23, 59).toISOString(),
+            canSendMessage: true
+          }
         ]}
         onSelect={noop}
         onCreate={noop}
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.stories.tsx
index cdfda369a2..508ea5082f 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.stories.tsx
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.stories.tsx
@@ -18,10 +18,23 @@ import { ConversationList } from './ConversationList';
 const NOW = new Date(2026, 9, 15, 12).getTime();
 
 const conversations: AssistantConversation[] = [
-  { id: 'a', title: 'Explain revenue by region', updatedAt: new Date(2026, 9, 15, 11).toISOString() },
-  { id: 'b', title: 'Draft a query for monthly orders', updatedAt: new Date(2026, 9, 10, 9).toISOString() },
+  {
+    id: 'a',
+    ownerId: 'user1',
+    title: 'Explain revenue by region',
+    updatedAt: new Date(2026, 9, 15, 11).toISOString(),
+    canSendMessage: true
+  },
+  {
+    id: 'b',
+    ownerId: 'user1',
+    title: 'Draft a query for monthly orders',
+    updatedAt: new Date(2026, 9, 10, 9).toISOString(),
+    canSendMessage: true
+  },
   {
     id: 'c',
+    ownerId: 'user2',
     title: 'Review shared analysis',
     canSendMessage: false,
     updatedAt: new Date(2026, 9, 9, 9).toISOString()


(zeppelin) 06/17: [ZEPPELIN-6746] Do not send on the Enter that confirms an IME composition in Safari

Posted by yo...@apache.org.
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 d72d9689cc431635a15bea43426d47d6f7bb5b23
Author: YONGJAE LEE <de...@gmail.com>
AuthorDate: Wed Oct 14 01:00:34 2026 +0900

    [ZEPPELIN-6746] Do not send on the Enter that confirms an IME composition in Safari
---
 .../src/entities/assistant/ui/AssistantComposer.spec.tsx               | 1 +
 .../zeppelin-react/src/entities/assistant/ui/AssistantComposer.tsx     | 3 ++-
 2 files changed, 3 insertions(+), 1 deletion(-)

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
index e643465d29..faf8004c7f 100644
--- 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
@@ -78,6 +78,7 @@ describe('AssistantComposer', () => {
     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.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.tsx
index 95b585874b..d0ede4c809 100644
--- 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
@@ -49,7 +49,8 @@ export const AssistantComposer = ({
   };
   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;
+    // 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();
   };


(zeppelin) 13/17: [ZEPPELIN-6746] Raise the composer placeholder contrast

Posted by yo...@apache.org.
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 c9a0944a2402c19d31036d1c50f953c9f646af2f
Author: kimyenac <ye...@gmail.com>
AuthorDate: Wed Oct 14 13:14:35 2026 +0900

    [ZEPPELIN-6746] Raise the composer placeholder contrast
---
 .../zeppelin-react/src/entities/assistant/ui/AssistantComposer.css.ts   | 2 ++
 1 file changed, 2 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
index 57f781c00e..0b93ea9448 100644
--- 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
@@ -50,6 +50,8 @@ export const input = style({
 });
 
 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',