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/18 07:47:40 UTC

(zeppelin) branch master updated: [ZEPPELIN-6747] Add AI Assistant conversation list and header components

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

voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new bf658867be [ZEPPELIN-6747] Add AI Assistant conversation list and header components
bf658867be is described below

commit bf658867be1067a208d3b41cd07cb0a3feb4ef06
Author: Lee SuJung <15...@users.noreply.github.com>
AuthorDate: Sun Oct 18 16:47:33 2026 +0900

    [ZEPPELIN-6747] Add AI Assistant conversation list and header components
    
    ### What is this PR for?
    
    Add `ConversationList` and `ConversationHeader`, the Assistant panel's conversation browsing UI, reviewable in Storybook without a server.
    
    **Components**
    * `ConversationList`
      * Takes conversation data and the selection state as props
      * Shows the list, search results, and an empty state
      * Shows "No conversations match your search." while a search is active and "No conversations yet." otherwise, so the message stays correct when the panel passes an already-filtered list
    * `ConversationHeader`
      * Shows the inner header for the selected conversation
    * Both report new conversation, selection, delete requests, and search term changes through callbacks
    * Deletion and its confirmation step are left to panel assembly (ZEPPELIN-6748)
    
    **Beyond the acceptance criteria**
    * The list groups conversations into "Today" (with a relative time) and "Earlier", within the issue's design latitude
    * Search shows a flat result list without sections
    * Read-only conversations (`canSendMessage === false`) show a lock and no delete button, and the read-only state is announced to screen readers
    
    **Title handling**
    * `title` is optional in the contract, and the server preserves an explicit empty title on create
    * A missing or empty title is shown as "Untitled conversation" in the list, the header, the row button name, and both delete labels, through a shared `conversationTitle` helper
    * Search matches the displayed title, so an untitled conversation is found by "untitled"
    
    **Dependencies**
    * #5561 is merged and this branch is rebased onto master
    * Depends on #5565 (ZEPPELIN-6743) per Jira
    
    ### What type of PR is it?
    
    Feature
    
    ### Todos
    
    * [x] Add `ConversationList` with search, distinct empty states, "Today"/"Earlier" sections, and delete requests
    * [x] Add `ConversationHeader` for the selected conversation
    * [x] Hide the delete button for read-only conversations
    * [x] Expose the read-only state and relative time to screen readers
    * [x] Show "Untitled conversation" for a missing or empty title, and match it in search
    * [x] Key the empty-result message on whether a search is active
    * [x] Check the second conversation's ID in the select and delete tests and story
    * [x] Add stories for the empty list, search, and select and delete, plus a selected conversation, "Today"/"Earlier", and the header states
    * [x] Add unit tests for filtering, sections, callbacks, the fallback title, the read-only marker, and relative time formats
    * [x] Rebase onto master and fill the fields the master contract requires in fixtures
    
    ### What is the Jira issue?
    
    ZEPPELIN-6747
    
    ### How should this be tested?
    
    Run from `zeppelin-web-angular/projects/zeppelin-react/`:
    
    ```bash
    npm ci
    npm test
    npm run typecheck
    npm run lint
    npm run build:storybook
    npm run test:storybook
    npm run storybook
    ```
    
    `npm test` runs 22 files and 208 tests, 26 of them new. `npm run test:storybook` runs every story in both themes (40 tests) and needs Playwright Chromium. In Storybook, check `Assistant/ConversationList` (Empty, Search, Selected, SelectAndDelete, TodayAndEarlier) and `Assistant/ConversationHeader` (Current, NoSelection, ReadOnly) in both the light and dark themes.
    
    ### Screenshots (if appropriate)
    
    N/A
    
    ### Questions:
    
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    Closes #5575 from xhaktm00/ZEPPELIN-6747.
    
    Signed-off-by: YONGJAE LEE <de...@gmail.com>
---
 .../assistant/ui/ConversationHeader.css.ts         |  35 ++
 .../assistant/ui/ConversationHeader.spec.tsx       |  77 +++++
 .../assistant/ui/ConversationHeader.stories.tsx    |  44 +++
 .../entities/assistant/ui/ConversationHeader.tsx   |  50 +++
 .../entities/assistant/ui/ConversationList.css.ts  | 106 ++++++
 .../assistant/ui/ConversationList.spec.tsx         | 367 +++++++++++++++++++++
 .../assistant/ui/ConversationList.stories.tsx      |  74 +++++
 .../src/entities/assistant/ui/ConversationList.tsx | 172 ++++++++++
 .../src/entities/assistant/ui/conversationTitle.ts |  15 +
 9 files changed, 940 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..88b4315d1c
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.spec.tsx
@@ -0,0 +1,77 @@
+/*
+ * 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 conversation = (title: string | undefined, canSendMessage = true): AssistantConversation => ({
+  id: 'a',
+  title,
+  ownerId: 'analyst',
+  canSendMessage,
+  updatedAt: '2026-10-15T11:00:00.000Z'
+});
+
+const noop = () => {};
+
+describe('ConversationHeader', () => {
+  it('shows the selected conversation title', () => {
+    render(
+      <ConversationHeader conversation={conversation('Explain revenue by region')} 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', () => {
+    render(<ConversationHeader conversation={conversation('Shared', false)} onCreate={noop} onDelete={noop} />);
+    expect(screen.queryByRole('button', { name: 'Delete Shared' })).toBeNull();
+    expect(screen.getByText('Read-only')).toBeTruthy();
+  });
+
+  it('shows a fallback title for a missing or empty title', () => {
+    const { rerender } = render(
+      <ConversationHeader conversation={conversation(undefined)} onCreate={noop} onDelete={noop} />
+    );
+    expect(screen.getByText('Untitled conversation')).toBeTruthy();
+    expect(screen.getByRole('button', { name: 'Delete Untitled conversation' })).toBeTruthy();
+    rerender(<ConversationHeader conversation={conversation('')} onCreate={noop} onDelete={noop} />);
+    expect(screen.getByText('Untitled conversation')).toBeTruthy();
+    expect(screen.getByRole('button', { name: 'Delete Untitled conversation' })).toBeTruthy();
+  });
+
+  it('reports the deleted conversation id without asking for confirmation', () => {
+    const onDelete = vi.fn();
+    render(
+      <ConversationHeader
+        conversation={conversation('Explain revenue by region')}
+        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..403f0b3286
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.stories.tsx
@@ -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 { 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',
+  ownerId: 'analyst',
+  canSendMessage: true,
+  updatedAt: '2026-10-15T11:00:00.000Z'
+};
+const readOnly: AssistantConversation = {
+  id: 'b',
+  title: 'Compare regional totals',
+  ownerId: 'analyst',
+  canSendMessage: false,
+  updatedAt: '2026-10-10T09:00:00.000Z'
+};
+
+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..c7c1f8e3ad
--- /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 { 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';
+import { conversationTitle } from './conversationTitle';
+import * as styles from './ConversationHeader.css';
+
+export interface ConversationHeaderProps {
+  conversation?: AssistantConversation;
+  onCreate: () => void;
+  onDelete: (conversationId: string) => void;
+}
+
+export const ConversationHeader = ({ conversation, onCreate, onDelete }: ConversationHeaderProps) => {
+  const title = conversation ? conversationTitle(conversation) : '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 ${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..855e56c527
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.spec.tsx
@@ -0,0 +1,367 @@
+/*
+ * 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 at = (day: number, hour: number, minute = 0, second = 0) =>
+  new Date(2026, 9, day, hour, minute, second).toISOString();
+
+const conversation = (id: string, title: string | undefined, updatedAt: string, canSendMessage = true) => ({
+  id,
+  title,
+  ownerId: 'analyst',
+  canSendMessage,
+  updatedAt
+});
+
+const conversations: AssistantConversation[] = [
+  conversation('a', 'Explain revenue by region', at(15, 11)),
+  conversation('b', 'Draft a query', at(10, 9)),
+  conversation('c', 'Shared analysis', at(9, 9), false)
+];
+
+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('shows the no-match message for an empty list passed while searching', () => {
+    render(
+      <ConversationList
+        conversations={[]}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    fireEvent.change(screen.getByRole('textbox', { name: 'Search conversations' }), { target: { value: 'query' } });
+    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: 'Draft a query' }));
+    expect(onSelect).toHaveBeenCalledWith('b');
+  });
+
+  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 Draft a query' }));
+    expect(onDelete).toHaveBeenCalledWith('b');
+  });
+
+  it('shows a fallback title for a missing or empty title', () => {
+    render(
+      <ConversationList
+        conversations={[conversation('x', undefined, at(10, 9)), conversation('y', '', at(10, 9))]}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    expect(screen.getAllByRole('button', { name: 'Untitled conversation' })).toHaveLength(2);
+    expect(screen.getAllByRole('button', { name: 'Delete Untitled conversation' })).toHaveLength(2);
+  });
+
+  it('matches an untitled conversation by its fallback title', () => {
+    render(
+      <ConversationList
+        conversations={[...conversations, conversation('x', '', at(10, 9))]}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    fireEvent.change(screen.getByRole('textbox', { name: 'Search conversations' }), {
+      target: { value: 'untitled' }
+    });
+    expect(screen.getByRole('button', { name: 'Untitled conversation' })).toBeTruthy();
+    expect(screen.queryByText('Draft a query')).toBeNull();
+  });
+
+  it('marks a read-only conversation and no other', () => {
+    render(
+      <ConversationList
+        conversations={conversations}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    expect(within(screen.getByRole('button', { name: /^Shared analysis/ })).getByText('Read-only')).toBeTruthy();
+    expect(screen.getAllByText('Read-only')).toHaveLength(1);
+  });
+
+  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={[
+          conversation('x', 'After midnight', at(15, 0, 1)),
+          conversation('y', 'Before midnight', at(14, 23, 59))
+        ]}
+        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('formats a relative time as now, minutes, or hours', () => {
+    render(
+      <ConversationList
+        conversations={[
+          conversation('x', 'Seconds ago', at(15, 11, 59, 40)),
+          conversation('y', 'Minutes ago', at(15, 11, 55)),
+          conversation('z', 'Hours ago', at(15, 9))
+        ]}
+        onSelect={noop}
+        onCreate={noop}
+        onDelete={noop}
+        onSearchTermChange={noop}
+        now={NOW}
+      />
+    );
+    expect(screen.getByText('now')).toBeTruthy();
+    expect(screen.getByText('5 min. ago')).toBeTruthy();
+    expect(screen.getByText('3 hr. ago')).toBeTruthy();
+  });
+
+  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..e04499ed1e
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.stories.tsx
@@ -0,0 +1,74 @@
+/*
+ * 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 at = (day: number, hour: number) => new Date(2026, 9, day, hour).toISOString();
+
+const conversations: AssistantConversation[] = [
+  { id: 'a', title: 'Explain revenue by region', ownerId: 'analyst', canSendMessage: true, updatedAt: at(15, 11) },
+  {
+    id: 'b',
+    title: 'Draft a query for monthly orders',
+    ownerId: 'analyst',
+    canSendMessage: true,
+    updatedAt: at(10, 9)
+  },
+  { id: 'c', title: 'Review shared analysis', ownerId: 'reviewer', canSendMessage: false, updatedAt: at(9, 9) }
+];
+
+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 Selected: Story = { args: { selectedId: 'a' } };
+
+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: 'Draft a query for monthly orders' }));
+    await expect(args.onSelect).toHaveBeenCalledWith('b');
+    await userEvent.click(canvas.getByRole('button', { name: 'Delete Draft a query for monthly orders' }));
+    await expect(args.onDelete).toHaveBeenCalledWith('b');
+  }
+};
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..bedb81cf30
--- /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 { 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';
+import { conversationTitle } from './conversationTitle';
+import * as styles from './ConversationList.css';
+
+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) =>
+  conversationTitle(conversation).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 = conversationTitle(conversation);
+  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">
+          {searching ? 'No conversations match your search.' : 'No conversations yet.'}
+        </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>
+  );
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/conversationTitle.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/conversationTitle.ts
new file mode 100644
index 0000000000..60720e36cb
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/conversationTitle.ts
@@ -0,0 +1,15 @@
+/*
+ * 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 { AssistantConversation } from '../model/assistantContract';
+
+export const conversationTitle = (conversation: AssistantConversation) => conversation.title || 'Untitled conversation';