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:41 UTC
(zeppelin) 10/17: [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 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>
+ );
+};