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 <[email protected]>
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 <[email protected]>
---
 .../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>
+  );
+};

Reply via email to