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))}
    } + + )} +
    + ); +};