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';