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..88b4315d1c4 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.spec.tsx @@ -0,0 +1,77 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { AssistantConversation } from '../model/assistantContract'; +import { ConversationHeader } from './ConversationHeader'; + +const conversation = (title: string | undefined, canSendMessage = true): AssistantConversation => ({ + id: 'a', + title, + ownerId: 'analyst', + canSendMessage, + updatedAt: '2026-10-15T11:00:00.000Z' +}); + +const noop = () => {}; + +describe('ConversationHeader', () => { + it('shows the selected conversation title', () => { + render( + + ); + 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', () => { + 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(); + 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..403f0b32867 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.stories.tsx @@ -0,0 +1,44 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import type { AssistantConversation } from '../model/assistantContract'; +import { ConversationHeader } from './ConversationHeader'; + +const current: AssistantConversation = { + id: 'a', + title: 'Explain how the notebook calculates revenue', + ownerId: 'analyst', + canSendMessage: true, + updatedAt: '2026-10-15T11:00:00.000Z' +}; +const readOnly: AssistantConversation = { + id: 'b', + title: 'Compare regional totals', + ownerId: 'analyst', + canSendMessage: false, + updatedAt: '2026-10-10T09:00:00.000Z' +}; + +const meta = { + title: 'Assistant/ConversationHeader', + component: ConversationHeader, + args: { conversation: current, onCreate: fn(), onDelete: fn() } +} satisfies Meta; + +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..c7c1f8e3add --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationHeader.tsx @@ -0,0 +1,50 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DeleteOutlined, LockOutlined, PlusOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; +import { visuallyHidden } from '@/shared/ui/assistant-theme/AssistantScope.css'; +import type { AssistantConversation } from '../model/assistantContract'; +import { conversationTitle } from './conversationTitle'; +import * as styles from './ConversationHeader.css'; + +export interface ConversationHeaderProps { + conversation?: AssistantConversation; + onCreate: () => void; + onDelete: (conversationId: string) => void; +} + +export const ConversationHeader = ({ conversation, onCreate, onDelete }: ConversationHeaderProps) => { + const title = conversation ? conversationTitle(conversation) : 'No conversation selected'; + const readOnly = conversation?.canSendMessage === false; + return ( +
+ {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..855e56c527c --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.spec.tsx @@ -0,0 +1,367 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { AssistantConversation } from '../model/assistantContract'; +import { ConversationList } from './ConversationList'; + +const NOW = new Date(2026, 9, 15, 12).getTime(); + +const at = (day: number, hour: number, minute = 0, second = 0) => + new Date(2026, 9, day, hour, minute, second).toISOString(); + +const conversation = (id: string, title: string | undefined, updatedAt: string, canSendMessage = true) => ({ + id, + title, + ownerId: 'analyst', + canSendMessage, + updatedAt +}); + +const conversations: AssistantConversation[] = [ + conversation('a', 'Explain revenue by region', at(15, 11)), + conversation('b', 'Draft a query', at(10, 9)), + conversation('c', 'Shared analysis', at(9, 9), false) +]; + +const noop = () => {}; + +describe('ConversationList', () => { + it('shows an empty-list message when there are no conversations', () => { + render( + + ); + 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('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( + + ); + 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: 'Draft a query' })); + expect(onSelect).toHaveBeenCalledWith('b'); + }); + + it('reports the deleted conversation id without asking for confirmation', () => { + const onDelete = vi.fn(); + render( + + ); + 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', () => { + 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('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( + + ); + 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..e04499ed1e3 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.stories.tsx @@ -0,0 +1,74 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import type { AssistantConversation } from '../model/assistantContract'; +import { ConversationList } from './ConversationList'; + +const NOW = new Date(2026, 9, 15, 12).getTime(); + +const at = (day: number, hour: number) => new Date(2026, 9, day, hour).toISOString(); + +const conversations: AssistantConversation[] = [ + { id: 'a', title: 'Explain revenue by region', ownerId: 'analyst', canSendMessage: true, updatedAt: at(15, 11) }, + { + id: 'b', + title: 'Draft a query for monthly orders', + ownerId: 'analyst', + canSendMessage: true, + updatedAt: at(10, 9) + }, + { id: 'c', title: 'Review shared analysis', ownerId: 'reviewer', canSendMessage: false, updatedAt: at(9, 9) } +]; + +const meta = { + title: 'Assistant/ConversationList', + component: ConversationList, + args: { + conversations, + onSelect: fn(), + onCreate: fn(), + onDelete: fn(), + onSearchTermChange: fn(), + now: NOW + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const TodayAndEarlier: Story = {}; + +export const Selected: Story = { args: { selectedId: 'a' } }; + +export const Empty: Story = { args: { conversations: [] } }; + +export const Search: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByRole('textbox', { name: 'Search conversations' }), 'query'); + await expect(args.onSearchTermChange).toHaveBeenCalledWith('query'); + await expect(canvas.getByText('Draft a query for monthly orders')).toBeInTheDocument(); + await expect(canvas.queryByText('Explain revenue by region')).toBeNull(); + } +}; + +export const SelectAndDelete: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: 'Draft a query for monthly orders' })); + await expect(args.onSelect).toHaveBeenCalledWith('b'); + await userEvent.click(canvas.getByRole('button', { name: 'Delete Draft a query for monthly orders' })); + await expect(args.onDelete).toHaveBeenCalledWith('b'); + } +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.tsx new file mode 100644 index 00000000000..bedb81cf30f --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/ConversationList.tsx @@ -0,0 +1,172 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState } from 'react'; +import { DeleteOutlined, LockOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons'; +import { Button, Input } from 'antd'; +import { isSameDay } from 'date-fns'; +import type { AssistantConversation } from '../model/assistantContract'; +import { conversationTitle } from './conversationTitle'; +import * as styles from './ConversationList.css'; + +const MINUTE = 60_000; +const relativeTime = new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'short' }); + +const formatRelative = (iso: string, now: number): string => { + const minutes = Math.round((Date.parse(iso) - now) / MINUTE); + if (minutes > -1) return 'now'; + if (minutes > -60) return relativeTime.format(minutes, 'minute'); + return relativeTime.format(Math.round(minutes / 60), 'hour'); +}; + +const matchesSearch = (conversation: AssistantConversation, needle: string) => + conversationTitle(conversation).toLowerCase().includes(needle); + +export interface ConversationListProps { + conversations: AssistantConversation[]; + selectedId?: string; + onSelect: (conversationId: string) => void; + onCreate: () => void; + onDelete: (conversationId: string) => void; + onSearchTermChange: (term: string) => void; + now?: number; +} + +const ConversationRow = ({ + conversation, + selected, + showTime, + now, + onSelect, + onDelete +}: { + conversation: AssistantConversation; + selected: boolean; + showTime: boolean; + now: number; + onSelect: (id: string) => void; + onDelete: (id: string) => void; +}) => { + const title = conversationTitle(conversation); + const readOnly = conversation.canSendMessage === false; + return ( +
  • + + {!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 ? ( +

    + {searching ? 'No conversations match your search.' : 'No conversations yet.'} +

    + ) : ( + <> + {!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))}
    } + + )} +
    + ); +}; 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';