From 664d1e53d3da85d537e53ce8b9913c723635c1d7 Mon Sep 17 00:00:00 2001 From: xhaktm00 <153787023+xhaktm00@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:59:19 +0900 Subject: [PATCH 1/2] [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 --- .../assistant/ui/ConversationHeader.css.ts | 35 +++ .../assistant/ui/ConversationHeader.spec.tsx | 52 ++++ .../ui/ConversationHeader.stories.tsx | 32 ++ .../assistant/ui/ConversationHeader.tsx | 50 ++++ .../assistant/ui/ConversationList.css.ts | 106 +++++++ .../assistant/ui/ConversationList.spec.tsx | 273 ++++++++++++++++++ .../assistant/ui/ConversationList.stories.tsx | 69 +++++ .../assistant/ui/ConversationList.tsx | 172 +++++++++++ 8 files changed, 789 insertions(+) create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.css.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.spec.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.stories.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.css.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.spec.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.stories.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.tsx 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 00000000000..c2a93b83978 --- /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 00000000000..bda08d10650 --- /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(); + expect(screen.getByText('Explain revenue by region')).toBeTruthy(); + }); + + it('shows a placeholder when no conversation is selected', () => { + render(); + 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(); + 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(); + 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(); + 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 00000000000..83e3de509df --- /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; + +export default meta; +type Story = StoryObj; + +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 00000000000..bf8fcb5bab9 --- /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 ( +
+ {readOnly && ( + <> +
+ ); +}; 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 00000000000..5dead331bb3 --- /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 00000000000..6909e74eb1f --- /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( + + ); + expect(screen.getByRole('status').textContent).toBe('No conversations yet.'); + }); + + it('shows a different message for a search with no matches', () => { + render( + + ); + 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( + + ); + 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( + + ); + fireEvent.change(screen.getByRole('textbox', { name: 'Search conversations' }), { target: { value: 'query' } }); + expect(onSearchTermChange).toHaveBeenCalledWith('query'); + }); + + it('marks the selected conversation', () => { + render( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + expect(screen.queryByRole('button', { name: 'Delete Shared analysis' })).toBeNull(); + }); + + it('reports a new-conversation request', () => { + const onCreate = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: 'New conversation' })); + expect(onCreate).toHaveBeenCalled(); + }); + + it('splits today from earlier conversations, each under its own section', () => { + render( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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 00000000000..cdfda369a2c --- /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; + +export default meta; +type Story = StoryObj; + +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 00000000000..d2ed38a1ab1 --- /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 ( +
  • + + {!readOnly && ( +
  • + ); +}; + +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) => ( + + ); + + return ( +
    +
    +

    Conversations

    +
    +
    + } + placeholder="Search conversations" + aria-label="Search conversations" + value={query} + onChange={event => handleSearchChange(event.target.value)} + /> +
    + {shown.length === 0 ? ( +

    + {conversations.length === 0 ? 'No conversations yet.' : 'No conversations match your search.'} +

    + ) : ( + <> + {!searching && today.length > 0 && ( + <> +
    Today
    +
      {today.map(conversation => row(conversation, true))}
    + + )} + {!searching && earlier.length > 0 && ( + <> +
    Earlier
    +
      {earlier.map(conversation => row(conversation, false))}
    + + )} + {searching &&
      {shown.map(conversation => row(conversation, false))}
    } + + )} +
    + ); +}; From 399056155beed38b2e2ac510ca225a5ebc2b7c00 Mon Sep 17 00:00:00 2001 From: xhaktm00 <153787023+xhaktm00@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:41:22 +0900 Subject: [PATCH 2/2] [ZEPPELIN-6747] Address review feedback on conversation list and header - Show "Untitled conversation" for a missing or empty title in the list, the header, and both labels, and match it in search - Key the empty-result message on whether a search is active - Check the second conversation's ID in the select and delete tests and story - Add a story with a selected conversation - Test the read-only marker and the relative time formats - Fill ownerId, canSendMessage, and updatedAt in fixtures for the master contract - Move the styles import last Co-Authored-By: Claude Opus 5.5 --- .../assistant/ui/ConversationHeader.spec.tsx | 37 +++++- .../ui/ConversationHeader.stories.tsx | 16 ++- .../assistant/ui/ConversationHeader.tsx | 8 +- .../assistant/ui/ConversationList.spec.tsx | 112 ++++++++++++++++-- .../assistant/ui/ConversationList.stories.tsx | 27 +++-- .../assistant/ui/ConversationList.tsx | 10 +- .../assistant/ui/conversationTitle.ts | 15 +++ 7 files changed, 188 insertions(+), 37 deletions(-) create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/conversationTitle.ts diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.spec.tsx index bda08d10650..88b4315d1c4 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.spec.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.spec.tsx @@ -15,12 +15,21 @@ 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', () => { - const conversation: AssistantConversation = { id: 'a', title: 'Explain revenue by region' }; - render(); + render( + + ); expect(screen.getByText('Explain revenue by region')).toBeTruthy(); }); @@ -30,15 +39,31 @@ describe('ConversationHeader', () => { }); it('offers no delete button for a read-only conversation', () => { - const conversation: AssistantConversation = { id: 'a', title: 'Shared', canSendMessage: false }; - render(); + render(); 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( + + ); + expect(screen.getByText('Untitled conversation')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Delete Untitled conversation' })).toBeTruthy(); + rerender(); + 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(); - const conversation: AssistantConversation = { id: 'a', title: 'Explain revenue by region' }; - render(); + render( + + ); fireEvent.click(screen.getByRole('button', { name: 'Delete Explain revenue by region' })); expect(onDelete).toHaveBeenCalledWith('a'); }); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.stories.tsx index 83e3de509df..403f0b32867 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.stories.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.stories.tsx @@ -15,8 +15,20 @@ import { fn } from 'storybook/test'; import type { AssistantConversation } from '../model/assistantContract'; import { ConversationHeader } from './ConversationHeader'; -const current: AssistantConversation = { id: 'a', title: 'Explain how the notebook calculates revenue' }; -const readOnly: AssistantConversation = { id: 'b', title: 'Compare regional totals', canSendMessage: false }; +const current: AssistantConversation = { + id: 'a', + 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', 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 index bf8fcb5bab9..c7c1f8e3add 100644 --- 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 @@ -10,12 +10,12 @@ * 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'; +import { conversationTitle } from './conversationTitle'; +import * as styles from './ConversationHeader.css'; export interface ConversationHeaderProps { conversation?: AssistantConversation; @@ -24,7 +24,7 @@ export interface ConversationHeaderProps { } export const ConversationHeader = ({ conversation, onCreate, onDelete }: ConversationHeaderProps) => { - const title = conversation ? conversation.title : 'No conversation selected'; + const title = conversation ? conversationTitle(conversation) : 'No conversation selected'; const readOnly = conversation?.canSendMessage === false; return (
    @@ -41,7 +41,7 @@ export const ConversationHeader = ({ conversation, onCreate, onDelete }: Convers size="small" type="text" icon={} - aria-label={`Delete ${conversation.title}`} + aria-label={`Delete ${title}`} onClick={() => onDelete(conversation.id)} /> )} diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.spec.tsx index 6909e74eb1f..855e56c527c 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.spec.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.spec.tsx @@ -17,10 +17,21 @@ 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[] = [ - { 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() } + 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 = () => {}; @@ -57,6 +68,21 @@ describe('ConversationList', () => { 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( + + ); + 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( { now={NOW} /> ); - fireEvent.click(screen.getByRole('button', { name: /^Explain revenue by region/ })); - expect(onSelect).toHaveBeenCalledWith('a'); + fireEvent.click(screen.getByRole('button', { name: 'Draft a query' })); + expect(onSelect).toHaveBeenCalledWith('b'); }); it('reports the deleted conversation id without asking for confirmation', () => { @@ -136,8 +162,56 @@ describe('ConversationList', () => { now={NOW} /> ); - fireEvent.click(screen.getByRole('button', { name: 'Delete Explain revenue by region' })); - expect(onDelete).toHaveBeenCalledWith('a'); + 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( + + ); + 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( + + ); + 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( + + ); + 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', () => { @@ -193,8 +267,8 @@ describe('ConversationList', () => { render( { expect(screen.getByRole('button', { name: 'Draft a query' }).querySelector('time')).toBeNull(); }); + it('formats a relative time as now, minutes, or hours', () => { + render( + + ); + 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( new Date(2026, 9, day, hour).toISOString(); + const conversations: AssistantConversation[] = [ - { id: 'a', title: 'Explain revenue by region', updatedAt: new Date(2026, 9, 15, 11).toISOString() }, - { id: 'b', title: 'Draft a query for monthly orders', updatedAt: new Date(2026, 9, 10, 9).toISOString() }, + { id: 'a', title: 'Explain revenue by region', ownerId: 'analyst', canSendMessage: true, updatedAt: at(15, 11) }, { - id: 'c', - title: 'Review shared analysis', - canSendMessage: false, - updatedAt: new Date(2026, 9, 9, 9).toISOString() - } + 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 = { @@ -46,6 +49,8 @@ type Story = StoryObj; export const TodayAndEarlier: Story = {}; +export const Selected: Story = { args: { selectedId: 'a' } }; + export const Empty: Story = { args: { conversations: [] } }; export const Search: Story = { @@ -61,9 +66,9 @@ export const Search: Story = { 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'); + 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 index d2ed38a1ab1..bedb81cf30f 100644 --- 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 @@ -10,13 +10,13 @@ * 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'; +import { conversationTitle } from './conversationTitle'; +import * as styles from './ConversationList.css'; const MINUTE = 60_000; const relativeTime = new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'short' }); @@ -29,7 +29,7 @@ const formatRelative = (iso: string, now: number): string => { }; const matchesSearch = (conversation: AssistantConversation, needle: string) => - (conversation.title ?? '').toLowerCase().includes(needle); + conversationTitle(conversation).toLowerCase().includes(needle); export interface ConversationListProps { conversations: AssistantConversation[]; @@ -56,7 +56,7 @@ const ConversationRow = ({ onSelect: (id: string) => void; onDelete: (id: string) => void; }) => { - const title = conversation.title; + const title = conversationTitle(conversation); const readOnly = conversation.canSendMessage === false; return (
  • @@ -148,7 +148,7 @@ export const ConversationList = ({ {shown.length === 0 ? (

    - {conversations.length === 0 ? 'No conversations yet.' : 'No conversations match your search.'} + {searching ? 'No conversations match your search.' : 'No conversations yet.'}

    ) : ( <> 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 00000000000..60720e36cb8 --- /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';