diff --git a/zeppelin-web-angular/e2e/models/notebook-assistant-page.ts b/zeppelin-web-angular/e2e/models/notebook-assistant-page.ts index 0403b0b1854..96410ed67c2 100644 --- a/zeppelin-web-angular/e2e/models/notebook-assistant-page.ts +++ b/zeppelin-web-angular/e2e/models/notebook-assistant-page.ts @@ -17,11 +17,31 @@ export class NotebookAssistantPage extends BasePage { readonly toggleButton: Locator; readonly panel: Locator; readonly closeButton: Locator; + readonly question: Locator; + readonly sendButton: Locator; + readonly messages: Locator; constructor(page: Page) { super(page); this.toggleButton = page.getByRole('button', { name: 'Toggle AI Assistant' }); this.panel = page.getByRole('complementary', { name: 'AI Assistant workspace' }); this.closeButton = page.getByRole('button', { name: 'Close AI Assistant' }); + this.question = this.panel.getByRole('textbox', { name: 'Question' }); + this.sendButton = this.panel.getByRole('button', { name: 'Send', exact: true }); + this.messages = this.panel.getByRole('region', { name: 'Conversation messages' }); + } + + async selectConversation(title: string): Promise { + await this.panel.getByRole('button', { name: 'Open conversations' }).click(); + await this.panel + .getByRole('region', { name: 'Conversations', exact: true }) + .getByRole('button') + .filter({ has: this.page.getByText(title, { exact: true }) }) + .click(); + } + + async sendQuestion(content: string): Promise { + await this.question.fill(content); + await this.sendButton.click(); } } diff --git a/zeppelin-web-angular/e2e/tests/notebook/assistant/assistant-conversation.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/assistant/assistant-conversation.spec.ts new file mode 100644 index 00000000000..0d447b78420 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/notebook/assistant/assistant-conversation.spec.ts @@ -0,0 +1,152 @@ +/* + * 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 { expect, test } from '@playwright/test'; +import { NotebookAssistantPage } from '../../../models/notebook-assistant-page'; +import { addPageAnnotationBeforeEach, createTestNotebook, PAGES, waitForZeppelinReady } from '../../../utils'; + +test.describe('Notebook AI Assistant fixture conversations', () => { + addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_ASSISTANT); + + let assistant: NotebookAssistantPage; + let noteId: string | undefined; + + test.beforeEach(async ({ page }) => { + noteId = undefined; + assistant = new NotebookAssistantPage(page); + + await page.goto('/#/'); + await waitForZeppelinReady(page); + ({ noteId } = await createTestNotebook(page)); + + await page.goto(`/#/notebook/${noteId}?reactAssistant=true`); + await waitForZeppelinReady(page); + await assistant.toggleButton.click(); + await expect(assistant.question).toBeEditable(); + }); + + test.afterEach(async ({ page }) => { + if (noteId) { + const response = await page.request.delete(`/api/notebook/${noteId}`); + expect(response.ok(), 'Delete the disposable notebook').toBe(true); + } + }); + + test('streams a new answer without remounting on completion and restores its saved history', async () => { + const question = 'Explain the revenue results for this new conversation.'; + + await assistant.sendQuestion(question); + + const answer = assistant.messages.getByRole('article', { name: 'Assistant answer', exact: true }); + await expect(answer).toHaveAttribute('aria-busy', 'true'); + await expect(answer).toContainText('I checked'); + const original = await answer.elementHandle(); + expect(original).not.toBeNull(); + + await expect(answer.getByRole('table')).toBeVisible(); + await expect(answer).not.toHaveAttribute('aria-busy', 'true'); + await expect(assistant.question).toBeEditable(); + await expect(assistant.messages).not.toHaveAttribute('aria-busy', 'true'); + expect(await original!.evaluate(element => element.isConnected)).toBe(true); + await expect(assistant.messages.getByRole('article', { name: 'Your question', exact: true })).toContainText( + question + ); + await expect(answer).toHaveCount(1); + + await assistant.closeButton.click(); + await expect(assistant.panel).toBeHidden(); + await assistant.toggleButton.click(); + await assistant.selectConversation('New conversation'); + + await expect(assistant.messages).not.toHaveAttribute('aria-busy', 'true'); + await expect(assistant.messages.getByRole('article', { name: 'Your question', exact: true })).toContainText( + question + ); + await expect(answer).toHaveCount(1); + await expect(answer.getByRole('table')).toBeVisible(); + }); + + test('keeps B draft and results separate when switching away from an active A run', async () => { + const aQuestion = 'Compare revenue for conversation A.'; + const bQuestion = 'Summarize the regions for conversation B.'; + const summary = 'This notebook compares monthly revenue across regions.'; + + await assistant.selectConversation('Notebook summary'); + await expect(assistant.messages).toContainText(summary); + await assistant.question.fill(bQuestion); + await assistant.selectConversation('Revenue analysis'); + await expect(assistant.question).toBeEditable(); + + await assistant.sendQuestion(aQuestion); + + await expect( + assistant.messages.getByRole('article', { name: 'Assistant answer', exact: true }).last() + ).toHaveAttribute('aria-busy', 'true'); + + await assistant.selectConversation('Notebook summary'); + await expect(assistant.question).toHaveValue(bQuestion); + await expect(assistant.question).toBeEditable(); + await expect(assistant.messages).toContainText(summary); + await expect(assistant.messages).not.toContainText(aQuestion); + + await assistant.sendButton.click(); + const bAnswer = assistant.messages.getByRole('article', { name: 'Assistant answer', exact: true }).last(); + + await expect(bAnswer.getByRole('table')).toBeVisible(); + await expect(bAnswer).not.toHaveAttribute('aria-busy', 'true'); + await expect(assistant.question).toBeEditable(); + await expect(assistant.messages).not.toHaveAttribute('aria-busy', 'true'); + await expect(assistant.messages.getByText(bQuestion, { exact: true })).toHaveCount(1); + await expect(assistant.messages).toContainText(summary); + await expect(assistant.messages).not.toContainText(aQuestion); + + await assistant.question.fill('Keep this next B draft.'); + + await assistant.selectConversation('Revenue analysis'); + await expect(assistant.messages.getByText(aQuestion, { exact: true })).toHaveCount(1); + await expect(assistant.messages.getByRole('table')).toHaveCount(1); + await expect(assistant.question).toBeEditable(); + await expect(assistant.messages).not.toContainText(bQuestion); + + await assistant.selectConversation('Notebook summary'); + + await expect(assistant.question).toHaveValue('Keep this next B draft.'); + await expect(assistant.messages.getByText(bQuestion, { exact: true })).toHaveCount(1); + await expect(assistant.messages.getByRole('table')).toHaveCount(1); + await expect(assistant.messages).toContainText(summary); + }); + + test('scrolling to earlier history joins the split answer without replacing the later answer', async () => { + await assistant.selectConversation('Revenue analysis'); + const laterAnswer = assistant.messages.getByRole('article', { name: 'Assistant answer', exact: true }).filter({ + hasText: 'Compare Europe’s order volume with its average order value.' + }); + await expect(laterAnswer).toBeVisible(); + const original = await laterAnswer.elementHandle(); + expect(original).not.toBeNull(); + + await assistant.messages.evaluate(element => { + element.scrollTop = 0; + }); + + await expect(assistant.messages.getByText('How did revenue change by region?', { exact: true })).toHaveCount(1); + await expect(assistant.messages).not.toHaveAttribute('aria-busy', 'true'); + const joinedAnswer = assistant.messages.getByRole('article', { name: 'Assistant answer', exact: true }).filter({ + hasText: 'I checked the regional revenue paragraph.' + }); + await expect(joinedAnswer).toContainText('Revenue increased in all three regions, led by Europe.'); + await expect(assistant.messages.getByRole('article', { name: 'Assistant answer', exact: true })).toHaveCount(2); + await expect(laterAnswer).toHaveCount(1); + expect(await original!.evaluate(element => element.isConnected)).toBe(true); + await expect(assistant.question).toBeEditable(); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx index 50edf035857..17afd101dc1 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx @@ -20,6 +20,7 @@ const answer = () => screen.getByRole('article', { name: 'Assistant answer' }); describe('AssistantAnswer', () => { it('shows only the current state as the run moves from loading to done', () => { const { rerender } = render(); + expect(screen.getByRole('status').textContent).toBe('Thinking…'); expect(answer().getAttribute('aria-busy')).toBe('true'); @@ -32,6 +33,7 @@ describe('AssistantAnswer', () => { }} /> ); + expect(screen.getByRole('status').textContent).toBe('Writing answer…'); expect(answer().textContent).toContain('Revenue is grouped'); @@ -50,6 +52,7 @@ describe('AssistantAnswer', () => { }} /> ); + expect(screen.queryByRole('status')).toBeNull(); expect(screen.queryByRole('alert')).toBeNull(); expect(answer().hasAttribute('aria-busy')).toBe(false); @@ -70,6 +73,7 @@ describe('AssistantAnswer', () => { }} /> ); + expect(screen.getByRole('status').textContent).toBe('Running read_results…'); expect(screen.getByText('Using 2 tools')).toBeTruthy(); const [first, second] = screen.getAllByRole('listitem'); @@ -77,19 +81,22 @@ describe('AssistantAnswer', () => { expect(within(second).getByRole('img', { name: 'Running' })).toBeTruthy(); }); - it('opens the tool log while a tool runs and collapses it when the answer completes', () => { + it('keeps a user-collapsed tool log closed through answer updates and completion', () => { const running: AssistantAnswerView = { status: 'streaming', content: '', tools: [{ id: 't1', name: 'list_paragraphs', status: 'running' }] }; - const toolLog = () => screen.getByText(/tool$/).closest('details')!; + const toolLog = () => within(answer()).getByRole('group') as HTMLDetailsElement; + const { rerender } = render(); + expect(toolLog().open).toBe(true); // A user who collapses the log keeps it collapsed while more of the answer arrives. toolLog().open = false; rerender(); + expect(toolLog().open).toBe(false); rerender( @@ -104,11 +111,80 @@ describe('AssistantAnswer', () => { }} /> ); + expect(toolLog().open).toBe(false); }); + it('preserves an explicitly expanded finished tool log when streaming completes', () => { + const { rerender } = render( + + ); + + const log = within(answer()).getByRole('group') as HTMLDetailsElement; + const summary = log.querySelector('summary')!; + log.open = true; + summary.focus(); + + rerender( + + ); + + expect(within(answer()).getByRole('group')).toHaveProperty('open', true); + expect(document.activeElement).toBe(summary); + }); + + it('keeps the native tool log and its focused summary when streaming fails', () => { + const tools = [ + { id: 't1', name: 'list_paragraphs', status: 'done' as const }, + { id: 't2', name: 'read_results', status: 'running' as const } + ]; + const { rerender } = render(); + const log = within(answer()).getByRole('group') as HTMLDetailsElement; + const summary = log.querySelector('summary')!; + log.open = false; + summary.focus(); + + rerender( + + ); + + expect(within(answer()).getByRole('group')).toBe(log); + expect(log.open).toBe(false); + expect(document.activeElement).toBe(summary); + expect(screen.queryByRole('status')).toBeNull(); + expect(screen.getByRole('alert').textContent).toContain('Connection lost'); + expect(answer().textContent).toContain('Revenue'); + expect(screen.getByRole('img', { name: 'Done' })).toBeTruthy(); + expect(screen.getByRole('img', { name: 'Status unknown' })).toBeTruthy(); + expect(screen.queryByRole('img', { name: 'Running' })).toBeNull(); + }); + it('labels a tool whose completion event carried no name', () => { render(); + expect(screen.getByRole('status').textContent).toBe('Running Tool…'); }); @@ -141,7 +217,9 @@ describe('AssistantAnswer', () => { message: 'The assistant stopped responding before the reply finished. Retry the request.', content: 'Revenue is grouped' }; + render(); + expect(screen.queryByRole('status')).toBeNull(); expect(answer().hasAttribute('aria-busy')).toBe(false); expect(answer().textContent).toContain('Revenue is grouped'); @@ -175,6 +253,7 @@ describe('AssistantAnswer', () => { renderContent={content => {content.replaceAll('*', '')}} /> ); + expect(answer().querySelector('strong')?.textContent).toBe('bold'); }); }); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx index 1139c73b2eb..c159194c7f0 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx @@ -11,6 +11,7 @@ */ import { Skeleton } from 'antd'; +import type { ReactNode } from 'react'; import { answerText, answerToolCalls } from '../model/messageHistory'; import type { AssistantContentRenderer, AssistantAnswerView, AssistantToolActivity } from './answerView'; import { AssistantAnswerError } from './AssistantAnswerError'; @@ -32,6 +33,7 @@ const Progress = ({ text }: { text: string }) => ( const progressText = (content: string, tools: AssistantToolActivity[]) => { const running = tools.find(tool => tool.status === 'running'); if (running) return `Running ${toolLabel(running)}…`; + return content ? 'Writing answer…' : 'Thinking…'; }; @@ -42,43 +44,66 @@ export interface AssistantAnswerProps { renderContent?: AssistantContentRenderer; } -/** One assistant answer in whichever state its run is in. Each state replaces the last, so only one shows at a time. */ -export const AssistantAnswer = ({ view, onRetry, renderContent }: AssistantAnswerProps) => { +const renderAnswerState = ( + view: AssistantAnswerView, + onRetry?: () => void +): { content: string; tools: AssistantToolActivity[]; streaming: boolean; details: ReactNode } => { switch (view.status) { case 'loading': - return ( - - - - - ); + return { + content: '', + tools: [], + streaming: true, + details: ( + <> + + + + ) + }; + case 'streaming': { const tools = view.tools ?? []; - return ( - - - - - ); - } - case 'done': { - const tools = answerToolCalls(view.message).map(call => ({ ...call, status: 'done' as const })); - return ( - - - - ); - } - case 'error': { - const tools = (view.tools ?? []).map(tool => - tool.status === 'running' ? { ...tool, status: 'unknown' as const } : tool - ); - return ( - - - - - ); + return { + content: view.content, + tools, + streaming: true, + details: + }; } + + case 'done': + return { + content: answerText(view.message), + tools: answerToolCalls(view.message).map(call => ({ ...call, status: 'done' as const })), + streaming: false, + details: null + }; + + case 'error': + return { + content: view.content ?? '', + tools: (view.tools ?? []).map(tool => + tool.status === 'running' ? { ...tool, status: 'unknown' as const } : tool + ), + streaming: false, + details: + }; } }; + +export const AssistantAnswer = ({ view, onRetry, renderContent }: AssistantAnswerProps) => { + const presentation = renderAnswerState(view, onRetry); + + return ( + + + {presentation.details} + + ); +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.spec.tsx index 1e0bc0d865b..7c8fa0ad276 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.spec.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.spec.tsx @@ -75,6 +75,50 @@ describe('AssistantComposer', () => { expect(onSubmit).not.toHaveBeenCalled(); }); + it('keeps a blocked draft editable and submits its edited value only after submission is enabled', () => { + const onChange = vi.fn(); + const onSubmit = vi.fn(); + const { rerender } = render( + + ); + const field = input(); + + expect(field.disabled).toBe(false); + expect(field.readOnly).toBe(false); + expect(send().disabled).toBe(true); + expect(screen.queryByRole('note')).toBeNull(); + + fireEvent.change(field, { target: { value: ' Edited question ' } }); + + expect(onChange).toHaveBeenCalledExactlyOnceWith(' Edited question '); + + rerender(); + fireEvent.click(send()); + pressEnter(); + fireEvent.submit(field.form!); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(input()).toBe(field); + expect(field.value).toBe(' Edited question '); + expect(field.disabled).toBe(false); + expect(field.readOnly).toBe(false); + + rerender( + + ); + + expect(input()).toBe(field); + expect(field.value).toBe(' Edited question '); + expect(send().disabled).toBe(false); + + fireEvent.click(send()); + pressEnter(); + fireEvent.submit(field.form!); + + expect(onSubmit.mock.calls).toEqual([['Edited question'], ['Edited question'], ['Edited question']]); + expect(field.value).toBe(' Edited question '); + }); + it('keeps the question visible but does not send it again while sending', () => { const { onSubmit } = renderComposer({ value: 'Explain this notebook', sending: true }); expect(input().readOnly).toBe(true); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.tsx index d0ede4c8099..de556bd9151 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantComposer.tsx @@ -23,6 +23,8 @@ export interface AssistantComposerProps { /** The question cannot be sent here, for example in a conversation started by another user. */ readOnly?: boolean; readOnlyReason?: string; + /** Prevents submission while the question remains editable. */ + submitDisabled?: boolean; /** A question is on its way: the input keeps its text but cannot be changed or sent again. */ sending?: boolean; } @@ -33,12 +35,13 @@ export const AssistantComposer = ({ onSubmit, readOnly = false, readOnlyReason = "You can't send messages to this conversation.", + submitDisabled = false, sending = false }: AssistantComposerProps) => { const hintId = useId(); const question = value.trim(); // The server rejects a blank question, and it accepts one question per conversation at a time. - const canSubmit = question !== '' && !readOnly && !sending; + const canSubmit = question !== '' && !readOnly && !sending && !submitDisabled; const submit = () => { if (canSubmit) onSubmit(question); @@ -81,7 +84,13 @@ export const AssistantComposer = ({ Shift + Enter for a new line - 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 index c2a93b83978..389c0e0f419 100644 --- 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 @@ -33,3 +33,20 @@ export const lock = style({ marginRight: vars.space1, color: vars.textTertiary }); + +export const listTrigger = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + width: 32, + height: 32, + border: 0, + borderRadius: vars.radiusSm, + background: 'transparent', + color: vars.text, + cursor: 'pointer', + selectors: { + '&:hover': { background: vars.fill } + } +}); 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 403f0b32867..3aa96ea1ee3 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 @@ -33,7 +33,7 @@ const readOnly: AssistantConversation = { const meta = { title: 'Assistant/ConversationHeader', component: ConversationHeader, - args: { conversation: current, onCreate: fn(), onDelete: fn() } + args: { conversation: current, onCreate: fn(), onDelete: fn(), onOpenList: fn() } } satisfies Meta; export default meta; 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 c7c1f8e3add..200af66f45a 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,7 +10,8 @@ * limitations under the License. */ -import { DeleteOutlined, LockOutlined, PlusOutlined } from '@ant-design/icons'; +import type { Ref } from 'react'; +import { DeleteOutlined, LockOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons'; import { Button } from 'antd'; import { visuallyHidden } from '@/shared/ui/assistant-theme/AssistantScope.css'; import type { AssistantConversation } from '../model/assistantContract'; @@ -20,14 +21,36 @@ import * as styles from './ConversationHeader.css'; export interface ConversationHeaderProps { conversation?: AssistantConversation; onCreate: () => void; + /** Disables only the New conversation action. */ + newDisabled?: boolean; onDelete: (conversationId: string) => void; + onOpenList?: () => void; + listTriggerRef?: Ref; } -export const ConversationHeader = ({ conversation, onCreate, onDelete }: ConversationHeaderProps) => { +export const ConversationHeader = ({ + conversation, + onCreate, + newDisabled, + onDelete, + onOpenList, + listTriggerRef +}: ConversationHeaderProps) => { const title = conversation ? conversationTitle(conversation) : 'No conversation selected'; const readOnly = conversation?.canSendMessage === false; return (
+ {onOpenList && ( + + )} {readOnly && ( <>
+
, panel.element @@ -114,7 +125,7 @@ export const mount = (element: HTMLElement, initialProps: AssistantWorkspaceProp const renderWith = (props: AssistantWorkspaceProps) => { root.render( - + diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/fixtures/assistantFixture.spec.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/fixtures/assistantFixture.spec.ts new file mode 100644 index 00000000000..ad2fc1b7d08 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/fixtures/assistantFixture.spec.ts @@ -0,0 +1,347 @@ +/* + * 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 { afterEach, describe, expect, it, vi } from 'vitest'; +import { + AssistantConnectionError, + type AssistantAnswerMessage, + type AssistantRunEvent +} from '@/entities/assistant/model/assistantContract'; +import { AssistantHttpError } from '@/entities/assistant/model/assistantTransport'; +import { answerToolCalls } from '@/entities/assistant/model/messageHistory'; +import type { AssistantDataSource } from '../model/assistantDataSource'; +import { createAssistantFixture, type AssistantFixtureOptions } from './assistantFixture'; +import { ASSISTANT_HISTORY_CONVERSATIONS, FIXTURE_NOW } from './assistantFixtureData'; + +const createFixture = (options: Pick = {}) => + createAssistantFixture({ + conversations: ASSISTANT_HISTORY_CONVERSATIONS, + now: FIXTURE_NOW, + delayMs: 0, + ...options + }); + +const collect = async (source: AssistantDataSource, id = 'c1', content = 'Compare this month with last month') => { + const events: AssistantRunEvent[] = []; + for await (const event of source.run(id, content, new AbortController().signal)) events.push(event); + return events; +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('assistant panel fixture', () => { + it('creates writable conversations without colliding with empty or sparse supplied histories', async () => { + const empty = createAssistantFixture({ conversations: [], now: FIXTURE_NOW, delayMs: 0 }); + + const first = await empty.api.createConversation({}); + await collect(empty, first.id, 'Question in an initially empty source'); + + expect((await empty.api.getMessages(first.id)).messages).toContainEqual( + expect.objectContaining({ role: 'user', content: 'Question in an initially empty source' }) + ); + + const seed = [ASSISTANT_HISTORY_CONVERSATIONS[0], ASSISTANT_HISTORY_CONVERSATIONS[2]]; + const source = createAssistantFixture({ conversations: seed, now: FIXTURE_NOW, delayMs: 0 }); + const existing = await source.api.getMessages('c1'); + + const created = await source.api.createConversation({}); + const next = await source.api.createConversation({}); + + expect(seed.map(value => value.conversation.id)).not.toContain(created.id); + expect(seed.map(value => value.conversation.id)).not.toContain(next.id); + expect(next.id).not.toBe(created.id); + + await collect(source, created.id, 'Question belonging only to the new conversation'); + + expect((await source.api.getMessages(created.id)).messages).toContainEqual( + expect.objectContaining({ role: 'user', content: 'Question belonging only to the new conversation' }) + ); + expect(await source.api.getMessages('c1')).toEqual(existing); + }); + + it('persists accepted questions and completed answer snapshots for history reload', async () => { + const source = createFixture(); + const before = await source.api.getMessages('c1'); + + const events = await collect(source); + + expect(events[0].type).toBe('run.started'); + expect(events.at(-1)?.type).toBe('run.completed'); + const snapshots = events.filter(event => event.type === 'message.done'); + expect(snapshots[0].messageId).not.toBe(snapshots.at(-1)?.messageId); + + const latest = await source.api.getMessages('c1'); + expect(latest.cursor).toBe(before.cursor); + expect(latest.messages.slice(0, before.messages.length)).toEqual(before.messages); + expect(latest.messages[before.messages.length]).toMatchObject({ + role: 'user', + content: 'Compare this month with last month' + }); + const answer = latest.messages.at(-1) as AssistantAnswerMessage; + expect(answer.id).toBe(snapshots.at(-1)?.messageId); + expect(answer.segments.map(({ id, content }) => ({ id, content }))).toEqual( + snapshots.map(event => ({ id: event.messageId, content: event.content })) + ); + expect(answerToolCalls(answer)).toEqual( + events + .filter(event => event.type === 'tool_call.started') + .map(event => ({ id: event.toolCallId, name: event.name })) + ); + + await source.api.deleteConversation('c1'); + + await expect(source.api.getMessages('c1')).rejects.toMatchObject({ status: 404 }); + }); + + it('stores the first question under the confirmed conversation id', async () => { + const source = createFixture(); + const created = await source.api.createConversation({}); + + expect(await source.api.getMessages(created.id)).toEqual({ messages: [], cursor: null }); + + await collect(source, created.id, 'Explain the results'); + + const page = await source.api.getMessages(created.id); + expect(page.messages[0]).toMatchObject({ role: 'user', content: 'Explain the results' }); + expect(page.messages.map(message => message.role)).toEqual(['user', 'assistant']); + expect((await source.api.listConversations())[0].id).toBe(created.id); + }); + + it('leaves the list unchanged after creation failure and permits an explicit retry', async () => { + const source = createFixture({ scenario: 'create-failure' }); + const before = await source.api.listConversations(); + + await expect(source.api.createConversation({})).rejects.toBeInstanceOf(AssistantHttpError); + expect(await source.api.listConversations()).toEqual(before); + + const created = await source.api.createConversation({}); + await collect(source, created.id, 'Retry after creation failed'); + + const page = await source.api.getMessages(created.id); + expect(page.messages.map(message => message.role)).toEqual(['user', 'assistant']); + expect(page.messages[0]).toMatchObject({ content: 'Retry after creation failed' }); + }); + + it('rejects a send before acceptance without persisting it and accepts one explicit retry', async () => { + const source = createFixture({ scenario: 'send-rejection' }); + const before = await source.api.getMessages('c1'); + + const rejected = await collect(source); + + expect(rejected.map(event => event.type)).toEqual(['run.failed']); + expect(await source.api.getMessages('c1')).toEqual(before); + + const events = await collect(source); + + expect(events[0].type).toBe('run.started'); + expect(events[events.length - 1].type).toBe('run.completed'); + const page = await source.api.getMessages('c1'); + expect( + page.messages.filter( + message => message.role === 'user' && message.content === 'Compare this month with last month' + ) + ).toHaveLength(1); + }); + + it('models unknown acceptance without inventing started or terminal knowledge', async () => { + const source = createFixture({ scenario: 'unknown' }); + const events: AssistantRunEvent[] = []; + const consume = async () => { + for await (const event of source.run('c1', 'Question with unknown outcome', new AbortController().signal)) { + events.push(event); + } + }; + + await expect(consume()).rejects.toBeInstanceOf(AssistantConnectionError); + expect(events).toEqual([]); + const page = await source.api.getMessages('c1'); + expect(page.messages.at(-1)).toMatchObject({ + role: 'user', + content: 'Question with unknown outcome' + }); + + const next = await collect(source, 'c1', 'A separate explicit question'); + + expect(next.at(-1)?.type).toBe('run.completed'); + }); + + it('keeps a failed current round out of persisted history and accepts a separate later question', async () => { + const source = createFixture({ scenario: 'run-failure' }); + const before = await source.api.getMessages('c1'); + + const events = await collect(source); + + expect(events.map(event => event.type)).toEqual(['run.started', 'message.delta', 'run.failed']); + const saved = await source.api.getMessages('c1'); + expect(saved.messages.slice(0, before.messages.length)).toEqual(before.messages); + expect(saved.messages.at(-1)).toMatchObject({ role: 'user', content: 'Compare this month with last month' }); + + const next = await collect(source, 'c1', 'A new question'); + + expect(next.at(-1)?.type).toBe('run.completed'); + expect((await source.api.getMessages('c1')).messages.at(-2)).toMatchObject({ + role: 'user', + content: 'A new question' + }); + }); + + it('isolates stored conversations and one-shot failures between source instances', async () => { + const first = createFixture({ scenario: 'send-rejection' }); + const second = createFixture({ scenario: 'send-rejection' }); + const listed = await second.api.listConversations(); + const history = await second.api.getMessages('c1'); + + await collect(first); + await collect(first); + await first.api.createConversation({}); + await first.api.deleteConversation('c2'); + + expect(await second.api.listConversations()).toEqual(listed); + expect(await second.api.getMessages('c1')).toEqual(history); + + const rejected = await collect(second); + + expect(rejected[0].type).toBe('run.failed'); + + const completed = await collect(second); + + expect(completed.at(-1)?.type).toBe('run.completed'); + }); + + it('reports read-only, missing and invalid cursor targets with HTTP errors', async () => { + const source = createFixture(); + const before = await source.api.listConversations(); + + await expect(collect(source, 'c3')).rejects.toMatchObject({ status: 403 }); + await expect(source.api.deleteConversation('c3')).rejects.toMatchObject({ status: 403 }); + await expect(collect(source, 'missing')).rejects.toMatchObject({ status: 404 }); + await expect(source.api.deleteConversation('missing')).rejects.toBeInstanceOf(AssistantHttpError); + await expect(source.api.getMessages('missing')).rejects.toMatchObject({ status: 404 }); + await expect(source.api.getMessages('c1', 'invalid')).rejects.toMatchObject({ status: 400 }); + expect(await source.api.listConversations()).toEqual(before); + }); + + it('aborts before acceptance and releases the conversation for deletion', async () => { + vi.useFakeTimers(); + const source = createFixture({ delayMs: 40 }); + const controller = new AbortController(); + const iterator = source.run('c1', 'Not accepted', controller.signal)[Symbol.asyncIterator](); + + const next = iterator.next(); + + await expect(source.api.deleteConversation('c1')).rejects.toMatchObject({ status: 409 }); + await expect(collect(source)).rejects.toMatchObject({ status: 409 }); + + controller.abort(); + + await expect(next).rejects.toMatchObject({ name: 'AbortError' }); + expect(vi.getTimerCount()).toBe(0); + + const history = source.api.getMessages('c1'); + await vi.advanceTimersByTimeAsync(40); + + expect( + (await history).messages.some(message => message.role === 'user' && message.content === 'Not accepted') + ).toBe(false); + + const deletion = source.api.deleteConversation('c1'); + await vi.advanceTimersByTimeAsync(40); + await deletion; + }); + + it('cleans up abort after acceptance without rolling back or resending the accepted question', async () => { + vi.useFakeTimers(); + const source = createFixture({ delayMs: 20 }); + const controller = new AbortController(); + const iterator = source.run('c1', 'Accepted before abort', controller.signal)[Symbol.asyncIterator](); + + const started = iterator.next(); + await vi.advanceTimersByTimeAsync(20); + + expect((await started).value).toMatchObject({ type: 'run.started' }); + + const next = iterator.next(); + controller.abort(); + + await expect(next).rejects.toMatchObject({ name: 'AbortError' }); + expect(vi.getTimerCount()).toBe(0); + + const history = source.api.getMessages('c1'); + await vi.advanceTimersByTimeAsync(20); + const page = await history; + + expect( + page.messages.filter(message => message.role === 'user' && message.content === 'Accepted before abort') + ).toHaveLength(1); + expect(page.messages.at(-1)).toMatchObject({ role: 'user', content: 'Accepted before abort' }); + + const deletion = source.api.deleteConversation('c1'); + await vi.advanceTimersByTimeAsync(20); + await deletion; + }); + + it('releases busy state when a consumer explicitly closes the iterator', async () => { + const source = createFixture(); + const iterator = source.run('c1', 'Accepted once', new AbortController().signal)[Symbol.asyncIterator](); + + const started = await iterator.next(); + + expect(started.value).toMatchObject({ type: 'run.started' }); + await expect(source.api.deleteConversation('c1')).rejects.toMatchObject({ status: 409 }); + + await iterator.return?.(); + + await expect(source.api.deleteConversation('c1')).resolves.toBeUndefined(); + }); + + it('rejects an already aborted signal without persisting or consuming its one-shot scenario', async () => { + const source = createFixture({ scenario: 'send-rejection' }); + const controller = new AbortController(); + const before = await source.api.getMessages('c1'); + controller.abort(); + + const iterator = source.run('c1', 'Never accepted', controller.signal)[Symbol.asyncIterator](); + + await expect(iterator.next()).rejects.toMatchObject({ name: 'AbortError' }); + expect(await source.api.getMessages('c1')).toEqual(before); + + const rejected = await collect(source); + + expect(rejected[0].type).toBe('run.failed'); + + await expect(source.api.deleteConversation('c1')).resolves.toBeUndefined(); + }); + + it('lets a different conversation finish while the first consumer remains active', async () => { + const source = createFixture(); + const first = source.run('c1', 'Keep this consumer open', new AbortController().signal)[Symbol.asyncIterator](); + + const started = await first.next(); + + expect(started.value).toMatchObject({ type: 'run.started' }); + + const completed = await collect(source, 'c2', 'Independent question'); + + expect(completed.at(-1)?.type).toBe('run.completed'); + await expect(source.api.deleteConversation('c1')).rejects.toMatchObject({ status: 409 }); + expect((await source.api.getMessages('c2')).messages.at(-2)).toMatchObject({ + role: 'user', + content: 'Independent question' + }); + + await first.return?.(); + + await expect(source.api.deleteConversation('c1')).resolves.toBeUndefined(); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/fixtures/assistantFixture.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/fixtures/assistantFixture.ts new file mode 100644 index 00000000000..3325b1993a5 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/fixtures/assistantFixture.ts @@ -0,0 +1,240 @@ +/* + * 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 { + AssistantConnectionError, + type AssistantAnswerMessage, + type AssistantConversation, + type AssistantMessage, + type AssistantRunEvent +} from '@/entities/assistant/model/assistantContract'; +import { AssistantHttpError } from '@/entities/assistant/model/assistantTransport'; +import type { AssistantDataSource } from '../model/assistantDataSource'; +import type { AssistantFixtureConversation } from './assistantFixtureData'; + +export type AssistantFixtureScenario = 'normal' | 'create-failure' | 'send-rejection' | 'unknown' | 'run-failure'; + +export interface AssistantFixtureOptions { + conversations: readonly AssistantFixtureConversation[]; + /** Reference time in milliseconds since the Unix epoch. */ + now: number; + /** Non-normal scenarios fail the first matching operation only. */ + scenario?: AssistantFixtureScenario; + delayMs?: number; +} + +interface FixtureConversation { + conversation: AssistantConversation; + latest: AssistantMessage[]; + earlier?: AssistantMessage[]; + busy: boolean; +} + +const copyMessages = (messages: readonly AssistantMessage[]): AssistantMessage[] => + messages.map(message => + message.role === 'user' + ? { ...message } + : { + ...message, + segments: message.segments.map(segment => ({ + ...segment, + ...(segment.toolCalls && { toolCalls: segment.toolCalls.map(call => ({ ...call })) }) + })) + } + ); + +const pause = (delayMs: number, signal?: AbortSignal): Promise => { + if (signal?.aborted) return Promise.reject(new DOMException('The operation was aborted.', 'AbortError')); + if (delayMs === 0) return Promise.resolve(); + + const { promise, resolve, reject } = Promise.withResolvers(); + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + reject(new DOMException('The operation was aborted.', 'AbortError')); + }; + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, delayMs); + + signal?.addEventListener('abort', onAbort, { once: true }); + return promise; +}; + +/** Creates an isolated in-memory conversation source from the supplied seed data. */ +export const createAssistantFixture = (options: AssistantFixtureOptions): AssistantDataSource => { + const scenario = options.scenario ?? 'normal'; + const delayMs = Math.max(0, options.delayMs ?? 120); + const now = options.now; + + let createFailureUsed = false; + let runFailureUsed = false; + let nextConversation = 1; + let nextRun = 1; + let activity = 0; + + const conversations = new Map(); + for (const value of options.conversations) { + conversations.set(value.conversation.id, { + conversation: { ...value.conversation }, + latest: copyMessages(value.latest), + ...(value.earlier && { earlier: copyMessages(value.earlier) }), + busy: false + }); + } + + const find = (id: string): FixtureConversation => { + const value = conversations.get(id); + if (!value) throw new AssistantHttpError(404); + + return value; + }; + + const writable = (id: string): FixtureConversation => { + const value = find(id); + if (!value.conversation.canSendMessage) throw new AssistantHttpError(403); + if (value.busy) throw new AssistantHttpError(409); + + return value; + }; + + return { + api: { + async listConversations() { + await pause(delayMs); + return Array.from(conversations.values(), value => ({ ...value.conversation })).sort((a, b) => + b.updatedAt.localeCompare(a.updatedAt) + ); + }, + async createConversation(body) { + await pause(delayMs); + + if (scenario === 'create-failure' && !createFailureUsed) { + createFailureUsed = true; + throw new AssistantHttpError(500, 'The fixture could not create the conversation. Try again.'); + } + + while (conversations.has(`c${nextConversation}`)) nextConversation++; + const id = `c${nextConversation++}`; + const value: FixtureConversation = { + conversation: { + id, + title: body.title ?? 'New conversation', + ownerId: 'demo-user', + canSendMessage: true, + updatedAt: new Date(now + ++activity * 1_000).toISOString() + }, + latest: [], + busy: false + }; + + conversations.set(id, value); + return { ...value.conversation }; + }, + async deleteConversation(id) { + writable(id); + await pause(delayMs); + writable(id); + conversations.delete(id); + }, + async getMessages(id, before) { + find(id); + await pause(delayMs); + + const value = find(id); + if (before !== undefined) { + if (!value.earlier || before !== `${id}:earlier`) + throw new AssistantHttpError(400, 'Unknown history cursor.'); + return { messages: copyMessages(value.earlier), cursor: null }; + } + + return { messages: copyMessages(value.latest), cursor: value.earlier ? `${id}:earlier` : null }; + } + }, + async *run(id, content, signal): AsyncIterable { + if (signal.aborted) throw new DOMException('The operation was aborted.', 'AbortError'); + + const value = writable(id); + value.busy = true; + + try { + await pause(delayMs, signal); + const failThisRun = !runFailureUsed && ['send-rejection', 'unknown', 'run-failure'].includes(scenario); + if (failThisRun) runFailureUsed = true; + + if (failThisRun && scenario === 'send-rejection') { + yield { type: 'run.failed', message: 'The fixture could not accept this message. Try again.' }; + return; + } + + const runId = `${id}-run-${nextRun++}`; + value.latest.push({ id: `${runId}-user`, role: 'user', content }); + value.conversation.updatedAt = new Date(now + ++activity * 1_000).toISOString(); + if (failThisRun && scenario === 'unknown') throw new AssistantConnectionError(); + yield { type: 'run.started', runId }; + + const answer: AssistantAnswerMessage = { id: `${runId}-answer-1`, role: 'assistant', segments: [] }; + const first = { id: answer.id, content: '' }; + const toolCallId = `${runId}-tool-1`; + const events: AssistantRunEvent[] = [{ type: 'message.delta', messageId: first.id, delta: 'I checked ' }]; + if (failThisRun && scenario === 'run-failure') { + events.push({ type: 'run.failed', runId, message: 'The fixture stopped while reading the revenue data.' }); + } else { + const secondId = `${runId}-answer-2`; + events.push( + { type: 'message.delta', messageId: first.id, delta: 'the revenue paragraph.' }, + { type: 'message.done', messageId: first.id, content: 'I checked the revenue paragraph.' }, + { type: 'tool_call.started', toolCallId, name: 'read_notebook' }, + { type: 'tool_call.done', toolCallId }, + { type: 'message.delta', messageId: secondId, delta: 'Revenue grew **12%**' }, + { + type: 'message.done', + messageId: secondId, + content: + 'Revenue grew **12%** compared with last month.\n\n| Region | Growth |\n| --- | ---: |\n| Europe | 18% |\n| Americas | 10% |\n| Asia Pacific | 8% |' + }, + { type: 'run.completed', runId } + ); + } + + for (const event of events) { + await pause(delayMs, signal); + if (event.type === 'message.delta' || event.type === 'message.done') { + let segment = answer.segments.find(item => item.id === event.messageId); + if (!segment) { + segment = { id: event.messageId, content: '' }; + answer.segments.push(segment); + } + segment.content = event.type === 'message.delta' ? segment.content + event.delta : event.content; + answer.id = event.messageId; + } else if (event.type === 'tool_call.started') { + answer.segments[0].toolCalls = [{ id: event.toolCallId, name: event.name }]; + } + + if (event.type === 'tool_call.done' || event.type === 'run.completed') { + const persisted = value.latest.findIndex( + message => message.role === 'assistant' && message.segments.some(segment => segment.id === first.id) + ); + const snapshot = copyMessages([answer])[0]; + if (persisted < 0) value.latest.push(snapshot); + else value.latest[persisted] = snapshot; + } + + yield event; + } + } finally { + value.busy = false; + } + } + }; +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/fixtures/assistantFixtureData.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/fixtures/assistantFixtureData.ts new file mode 100644 index 00000000000..cb8013170a6 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/fixtures/assistantFixtureData.ts @@ -0,0 +1,130 @@ +/* + * 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, AssistantMessage } from '@/entities/assistant/model/assistantContract'; + +/** Fixed reference clock for reproducible conversation dates. */ +export const FIXTURE_NOW = Date.UTC(2026, 9, 19, 12); + +export interface AssistantFixtureConversation { + conversation: AssistantConversation; + latest: readonly AssistantMessage[]; + earlier?: readonly AssistantMessage[]; +} + +const revenueConversation: AssistantConversation = { + id: 'c1', + title: 'Revenue analysis', + ownerId: 'demo-user', + canSendMessage: true, + updatedAt: new Date(FIXTURE_NOW - 60_000).toISOString() +}; + +const notebookSummary: AssistantFixtureConversation = { + conversation: { + id: 'c2', + title: 'Notebook summary', + ownerId: 'demo-user', + canSendMessage: true, + updatedAt: new Date(FIXTURE_NOW - 86_400_000).toISOString() + }, + latest: [ + { id: 'c2-user-1', role: 'user', content: 'Summarize this notebook.' }, + { + id: 'c2-answer-1', + role: 'assistant', + segments: [{ id: 'c2-answer-1', content: 'This notebook compares monthly revenue across regions.' }] + } + ] +}; + +const sharedAnalysis: AssistantFixtureConversation = { + conversation: { + id: 'c3', + title: 'Shared analysis', + ownerId: 'teammate', + canSendMessage: false, + updatedAt: new Date(FIXTURE_NOW - 172_800_000).toISOString() + }, + latest: [ + { + id: 'c3-answer-1', + role: 'assistant', + segments: [{ id: 'c3-answer-1', content: 'This shared conversation can be read, but not changed.' }] + } + ] +}; + +/** Compact writable and read-only conversations for trying the panel. */ +export const ASSISTANT_DEMO_CONVERSATIONS: readonly AssistantFixtureConversation[] = [ + { + conversation: revenueConversation, + latest: [ + { id: 'c1-user-2', role: 'user', content: 'Which region should we investigate next?' }, + { + id: 'c1-answer-2', + role: 'assistant', + segments: [{ id: 'c1-answer-2', content: 'Compare Europe’s order volume with its average order value.' }] + } + ] + }, + notebookSummary, + sharedAnalysis +]; + +/** Two chronological pages with an answer continuing across the history boundary. */ +export const ASSISTANT_HISTORY_CONVERSATIONS: readonly AssistantFixtureConversation[] = [ + { + conversation: revenueConversation, + earlier: [ + { id: 'c1-user-1', role: 'user', content: 'How did revenue change by region?' }, + { + id: 'c1-answer-1a', + role: 'assistant', + segments: [ + { + id: 'c1-answer-1a', + content: 'I checked the regional revenue paragraph.', + toolCalls: [{ id: 'c1-tool-1', name: 'read_notebook' }] + } + ] + } + ], + latest: [ + { + id: 'c1-answer-1b', + role: 'assistant', + segments: [{ id: 'c1-answer-1b', content: 'Revenue increased in all three regions, led by Europe.' }] + }, + { id: 'c1-user-2', role: 'user', content: 'Which region should we investigate next?' }, + { + id: 'c1-answer-2', + role: 'assistant', + segments: [ + { + id: 'c1-answer-2', + content: [ + 'Compare Europe’s order volume with its average order value.', + 'Start with the monthly totals in the regional revenue paragraph. Use the same reporting period for each region.', + 'Then separate the change in order count from the change in average order value. Either can explain revenue growth.', + 'Check whether the regional mix changed. A larger share of high-value orders can raise revenue without a similar rise in volume.', + 'Compare those observations with the Americas and Asia Pacific before drawing a conclusion about Europe.', + 'The earlier answer describes the initial regional comparison. Scroll to the top to read that part of this conversation.' + ].join('\n\n') + } + ] + } + ] + }, + notebookSummary, + sharedAnalysis +]; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/lib/useConversationScroll.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/lib/useConversationScroll.ts new file mode 100644 index 00000000000..c1838273a53 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/lib/useConversationScroll.ts @@ -0,0 +1,296 @@ +/* + * 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 { useLayoutEffect, useRef } from 'react'; + +export type ConversationRowSegments = ReadonlyMap; + +interface Anchor { + row: HTMLElement; + block: HTMLElement; + text: string; + blockIndex: number; + blockCount: number; + firstSegment?: string; + blockOffset: number; + rowOffset: number; + neighbors: Array<{ row: HTMLElement; offset: number }>; +} + +interface ScrollState { + scope: object; + key: string; + follow: boolean; + anchor?: Anchor; + expectedTop?: number; + interacting: boolean; + top: number; + height: number; + width: number; + contentHeight: number; +} + +const markdownBlocks = + '[data-conversation-markdown] p, [data-conversation-markdown] pre, ' + + '[data-conversation-markdown] table, [data-conversation-markdown] li, ' + + '[data-conversation-markdown] h1, [data-conversation-markdown] h2, ' + + '[data-conversation-markdown] h3, [data-conversation-markdown] blockquote'; + +const captureAnchor = ( + viewport: HTMLElement, + content: HTMLElement, + rows: ConversationRowSegments +): Anchor | undefined => { + const top = viewport.getBoundingClientRect().top; + const bottom = top + viewport.clientHeight; + + for (const row of Array.from(content.querySelectorAll('[data-message-key]'))) { + const rect = row.getBoundingClientRect(); + if (rect.bottom <= top || rect.top >= bottom) continue; + + // Anchor within an answer because older pages can extend that same row. + const blocks = Array.from(row.querySelectorAll(markdownBlocks)); + const block = + blocks.find(element => { + const bounds = element.getBoundingClientRect(); + return bounds.bottom > top && bounds.top < bottom; + }) ?? row; + + const neighbors = [row.nextElementSibling, row.previousElementSibling] + .filter( + (element): element is HTMLElement => element instanceof HTMLElement && element.hasAttribute('data-message-key') + ) + .map(element => ({ row: element, offset: element.getBoundingClientRect().top - top })); + + return { + row, + block, + text: block.textContent ?? '', + blockIndex: blocks.indexOf(block), + blockCount: blocks.length, + firstSegment: rows.get(row.dataset.messageKey!)?.[0]?.id, + blockOffset: block.getBoundingClientRect().top - top, + rowOffset: rect.top - top, + neighbors + }; + } + + return undefined; +}; + +/** Keeps the reader's position when rendered messages or viewport dimensions change. */ +export const useConversationScroll = ({ + scope, + conversationKey, + rowSegments, + visible, + loadEarlier +}: { + scope: object; + conversationKey: string; + rowSegments: ConversationRowSegments; + visible: boolean; + loadEarlier: () => Promise; +}) => { + const viewport = useRef(null); + const content = useRef(null); + const state = useRef(undefined); + + const latest = useRef({ rowSegments, loadEarlier }); + latest.current = { rowSegments, loadEarlier }; + + const reconcile = useRef<() => void>(() => undefined); + const schedule = useRef<() => void>(() => undefined); + + useLayoutEffect(() => { + if (!state.current || state.current.scope !== scope || state.current.key !== conversationKey) { + state.current = { + scope, + key: conversationKey, + follow: true, + interacting: false, + top: 0, + height: 0, + width: 0, + contentHeight: 0 + }; + } + + const current = state.current; + const element = viewport.current; + const stack = content.current; + if (!visible || !element || !stack) return; + + let disposed = false; + let frame: number | undefined; + + const writeTop = (top: number) => { + const target = Math.max(0, Math.min(top, element.scrollHeight - element.clientHeight)); + if (Math.abs(element.scrollTop - target) <= 1) return; + + element.scrollTop = target; + current.expectedTop = element.scrollTop; + }; + + const update = () => { + if (disposed || element.clientHeight === 0 || element.clientWidth === 0) return; + + const geometryChanged = + current.height !== element.clientHeight || + current.width !== element.clientWidth || + current.contentHeight !== element.scrollHeight; + + if (current.follow) { + if (geometryChanged) writeTop(element.scrollHeight); + } else if (current.anchor) { + const { row, block, text, blockIndex, blockCount, firstSegment, rowOffset, blockOffset, neighbors } = + current.anchor; + const blocks = stack.contains(row) ? Array.from(row.querySelectorAll(markdownBlocks)) : []; + + // Repeated paragraphs need segment identity, not text matching alone. + const prepended = + firstSegment && + (latest.current.rowSegments.get(row.dataset.messageKey!)?.findIndex(segment => segment.id === firstSegment) ?? + -1) > 0; + const index = blockIndex + (prepended ? blocks.length - blockCount : 0); + const indexed = blocks[index]?.textContent?.startsWith(text) ? blocks[index] : undefined; + const retainedBlock = + prepended && indexed + ? indexed + : stack.contains(block) && block.textContent?.startsWith(text) + ? block + : (indexed ?? blocks.find(candidate => candidate.textContent === text)); + + const fallback = stack.contains(row) + ? { row, offset: rowOffset } + : neighbors.find(candidate => stack.contains(candidate.row)); + const target = retainedBlock ?? fallback?.row; + + if (target) { + const offset = retainedBlock ? blockOffset : fallback!.offset; + + // Exclude resize clamping so anchor correction preserves native scrolling. + const clampedTop = Math.max(0, Math.min(current.top, element.scrollHeight - element.clientHeight)); + const readerMovement = element.scrollTop - clampedTop; + writeTop( + element.scrollTop + + target.getBoundingClientRect().top - + element.getBoundingClientRect().top - + offset + + readerMovement + ); + } + } + + current.anchor = current.follow ? undefined : captureAnchor(element, stack, latest.current.rowSegments); + current.height = element.clientHeight; + current.width = element.clientWidth; + current.contentHeight = element.scrollHeight; + current.top = element.scrollTop; + + if (element.scrollHeight <= element.clientHeight + 1 || element.scrollTop <= 16) { + void latest.current.loadEarlier().then(loaded => { + if (loaded && !disposed) schedule.current(); + }); + } + }; + + const queue = () => { + if (disposed || frame !== undefined) return; + frame = requestAnimationFrame(() => { + frame = undefined; + update(); + }); + }; + + reconcile.current = update; + schedule.current = queue; + + const interaction = (event: Event) => { + if (event instanceof KeyboardEvent) { + if (!['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) return; + if ( + event.target instanceof Element && + event.target.closest('input, textarea, select, button, summary, [contenteditable]') + ) + return; + } + if (event.type === 'pointerdown' && event.target !== element) return; + + current.interacting = true; + current.expectedTop = undefined; + }; + + element.addEventListener('wheel', interaction, { passive: true }); + element.addEventListener('touchmove', interaction, { passive: true }); + element.addEventListener('keydown', interaction); + element.addEventListener('pointerdown', interaction); + + const observer = new ResizeObserver(queue); + observer.observe(element); + observer.observe(stack); + update(); + + return () => { + disposed = true; + observer.disconnect(); + element.removeEventListener('wheel', interaction); + element.removeEventListener('touchmove', interaction); + element.removeEventListener('keydown', interaction); + element.removeEventListener('pointerdown', interaction); + if (frame !== undefined) cancelAnimationFrame(frame); + reconcile.current = () => undefined; + schedule.current = () => undefined; + }; + }, [scope, conversationKey, visible]); + + useLayoutEffect(() => { + if (visible) reconcile.current(); + }); + + const onScroll = () => { + const element = viewport.current; + const stack = content.current; + const current = state.current; + if (!visible || !element || !stack || !current || element.clientHeight === 0) return; + + if ( + !current.interacting && + current.expectedTop !== undefined && + Math.abs(element.scrollTop - current.expectedTop) <= 1 + ) { + current.expectedTop = undefined; + return; + } + + current.expectedTop = undefined; + // Resize-generated scroll events must not change follow mode. + if ( + !current.interacting && + (current.height !== element.clientHeight || + current.width !== element.clientWidth || + current.contentHeight !== element.scrollHeight) + ) { + schedule.current(); + return; + } + + current.interacting = false; + current.follow = + element.scrollTop >= current.top - 1 && element.scrollHeight - element.scrollTop - element.clientHeight <= 48; + current.top = element.scrollTop; + current.anchor = current.follow ? undefined : captureAnchor(element, stack, latest.current.rowSegments); + schedule.current(); + }; + + return { viewport, content, onScroll }; +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/lib/useDeleteConfirmation.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/lib/useDeleteConfirmation.ts new file mode 100644 index 00000000000..929a5c97b11 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/lib/useDeleteConfirmation.ts @@ -0,0 +1,145 @@ +/* + * 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 { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import type { AssistantConversation } from '@/entities/assistant/model/assistantContract'; +import { conversationTitle } from '@/entities/assistant/ui/conversationTitle'; +import type { AssistantDeletionResult } from '../model/assistantPanelState'; + +interface DeleteConfirmationOptions { + scope: object; + conversations: readonly AssistantConversation[]; + listLoading: boolean; + deletingConversationIds: readonly string[]; + onDelete: (id: string) => Promise; + fallbackFocus: () => HTMLElement | null; +} + +interface DeleteTarget { + id: string; + title: string; +} + +type DeleteInteraction = { + scope: object; + trigger: HTMLElement | null; +} & ( + | { kind: 'open'; target: DeleteTarget; status: 'ready' | 'pending' } + | { kind: 'open'; target: DeleteTarget; status: 'failed'; message: string } + | { kind: 'closed' } +); + +/** Closing the confirmation does not cancel an already requested deletion. */ +export const useDeleteConfirmation = ({ + scope, + conversations, + listLoading, + deletingConversationIds, + onDelete, + fallbackFocus +}: DeleteConfirmationOptions) => { + const [interaction, setInteraction] = useState(null); + const current = useRef(null); + + // Event handlers must see state changes before React commits. + const transition = useCallback((next: DeleteInteraction | null) => { + current.current = next; + setInteraction(next); + }, []); + + useLayoutEffect(() => { + transition(null); + + return () => { + current.current = null; + }; + }, [scope, transition]); + + useLayoutEffect(() => { + if ( + interaction?.scope !== scope || + interaction.kind !== 'open' || + listLoading || + conversations.some(conversation => conversation.id === interaction.target.id) + ) + return; + + transition({ kind: 'closed', scope, trigger: interaction.trigger }); + }, [interaction, scope, listLoading, conversations, transition]); + + useLayoutEffect(() => { + if (interaction?.scope !== scope || interaction.kind !== 'closed') return; + + transition(null); + + const target = interaction.trigger?.isConnected ? interaction.trigger : fallbackFocus(); + target?.focus(); + }, [interaction, scope, fallbackFocus, transition]); + + const request = (id: string) => { + const conversation = conversations.find(item => item.id === id); + if (!conversation || conversation.canSendMessage === false) return; + + transition({ + kind: 'open', + scope, + target: { id, title: conversationTitle(conversation) }, + trigger: document.activeElement instanceof HTMLElement ? document.activeElement : null, + status: 'ready' + }); + }; + + const cancel = () => { + const active = current.current; + if (active?.scope !== scope || active.kind !== 'open') return; + + transition({ kind: 'closed', scope, trigger: active.trigger }); + }; + + const confirm = async () => { + const active = current.current; + if ( + active?.scope !== scope || + active.kind !== 'open' || + active.status === 'pending' || + deletingConversationIds.includes(active.target.id) + ) + return; + + const pending: DeleteInteraction = { + kind: 'open', + scope, + trigger: active.trigger, + target: active.target, + status: 'pending' + }; + transition(pending); + + const result = await onDelete(active.target.id); + if (current.current !== pending) return; + + if (result.status === 'deleted') transition({ kind: 'closed', scope, trigger: pending.trigger }); + else if (result.status === 'failed') transition({ ...pending, status: 'failed', message: result.message }); + else transition({ ...pending, status: 'ready' }); + }; + + const active = interaction?.scope === scope && interaction.kind === 'open' ? interaction : null; + + return { + target: active?.target ?? null, + deleting: !!active && (active.status === 'pending' || deletingConversationIds.includes(active.target.id)), + error: active?.status === 'failed' ? active.message : undefined, + request, + cancel, + confirm + }; +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantConversation.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantConversation.ts new file mode 100644 index 00000000000..7c9aa3d4f68 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantConversation.ts @@ -0,0 +1,305 @@ +/* + * 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 { AssistantStreamError, type AssistantConversation } from '@/entities/assistant/model/assistantContract'; +import type { AssistantDataSource } from './assistantDataSource'; +import { AssistantHistory } from './assistantHistory'; +import type { + AssistantDeletionResult, + AssistantPanelEntry, + AssistantPanelFailure, + AssistantPanelPhase +} from './assistantPanelState'; +import { AssistantRun, type AssistantRunSnapshot } from './assistantRun'; + +const UNKNOWN_OUTCOME = + 'The outcome of an earlier question is unknown. Reload can show saved messages, but cannot establish whether that run has finished. Sending again is an explicit new submission.'; + +export class AssistantConversationSession { + private conversation?: AssistantConversation; + private draft = ''; + private phase: AssistantPanelPhase = 'idle'; + private failure?: AssistantPanelFailure; + private uncertainty?: string; + private submittedQuestion?: string; + private deleting = false; + private historyRefreshPending = false; + + private disposed = false; + private controller?: AbortController; + + private readonly history: AssistantHistory; + private entry: AssistantPanelEntry; + + constructor( + readonly key: string, + conversation: AssistantConversation | undefined, + private readonly source: AssistantDataSource, + private readonly changed: () => void, + private readonly confirmed: (session: AssistantConversationSession, conversation: AssistantConversation) => void, + private readonly activity: () => void + ) { + this.conversation = conversation; + this.history = new AssistantHistory(key, !!conversation, this.publish); + this.entry = this.project(); + } + + get snapshot(): AssistantPanelEntry { + return this.entry; + } + + /** Does not emit a change notification. */ + updateConversation(conversation: AssistantConversation): void { + const current = this.conversation; + if (this.disposed || current?.id !== conversation.id) return; + + if ( + current.title === conversation.title && + current.ownerId === conversation.ownerId && + current.canSendMessage === conversation.canSendMessage && + current.updatedAt === conversation.updatedAt + ) { + return; + } + + this.conversation = conversation; + this.entry = this.project(); + } + + private project(): AssistantPanelEntry { + return { + ...this.history.snapshot, + key: this.key, + conversation: this.conversation, + draft: this.draft, + phase: this.phase, + failure: this.failure, + uncertainty: this.uncertainty, + submittedQuestion: this.submittedQuestion, + deleting: this.deleting + }; + } + + private readonly publish = (): void => { + if (this.disposed) return; + + this.entry = this.project(); + this.changed(); + }; + + /** Draft-only edits preserve the current message snapshot. */ + setDraft(value: string): void { + if (this.disposed || this.draft === value) return; + + this.draft = value; + this.entry = { ...this.entry, draft: value }; + this.changed(); + } + + ensureHistory(): void { + const history = this.history.snapshot; + if (!history.loaded && !history.loading && !this.deleting && this.phase === 'idle') void this.reload(); + } + + async reload(preserveFailure = false): Promise { + if (this.disposed || !this.conversation || this.deleting || (this.phase !== 'idle' && this.phase !== 'unknown')) { + return; + } + + this.historyRefreshPending = false; + if (!preserveFailure) this.failure = undefined; + const result = await this.history.loadLatest(this.source.api, this.conversation.id); + if (this.disposed || result.status === 'ignored' || !this.history.isCurrent(result)) return; + + if (result.status === 'failed') { + this.failure = { + message: result.error instanceof Error ? result.error.message : 'Could not load conversation history.', + kind: 'connection', + recovery: 'reload' + }; + } else if (this.phase !== 'unknown' && !preserveFailure && this.history.snapshot.answerRecovery !== 'send') { + this.submittedQuestion = undefined; + } + + this.publish(); + } + + private async refreshHistory(): Promise { + if (!this.historyRefreshPending || this.disposed || this.deleting || this.phase !== 'idle') return; + + await this.reload(this.failure?.kind === 'failed'); + } + + async loadEarlier(): Promise { + if (this.disposed || !this.conversation || this.deleting || (this.phase !== 'idle' && this.phase !== 'unknown')) { + return false; + } + if (!this.history.canLoadEarlier) return false; + + this.failure = undefined; + const result = await this.history.loadEarlier(this.source.api, this.conversation.id); + if (this.disposed || result.status === 'ignored' || !this.history.isCurrent(result)) return false; + + if (result.status === 'failed') { + this.failure = { + message: result.error instanceof Error ? result.error.message : 'Could not load earlier messages.', + kind: 'connection', + recovery: 'reload' + }; + } else { + this.failure = undefined; + } + + this.publish(); + return result.status === 'committed'; + } + + /** Existing conversations must load their initial history page before sending. */ + async send(question?: string): Promise { + const submitted = question ?? this.draft; + const content = submitted.trim(); + + if ( + this.disposed || + !content || + (this.phase !== 'idle' && this.phase !== 'unknown') || + this.deleting || + (this.conversation && !this.history.snapshot.loaded) || + this.conversation?.canSendMessage === false + ) { + return; + } + + const controller = new AbortController(); + this.controller = controller; + this.draft = submitted; + this.submittedQuestion = content; + this.failure = undefined; + this.phase = this.conversation ? 'pending' : 'creating'; + if (this.history.snapshot.loading) this.historyRefreshPending = true; + const answerKey = this.history.beginTurn(); + + const conversation = this.conversation ?? (await this.createConversation(controller)); + if (!conversation || this.disposed || this.controller !== controller) return; + + const run = new AssistantRun(answerKey); + const applyRunTransition = (previous: AssistantRunSnapshot, next: AssistantRunSnapshot) => { + if (!previous.accepted && next.accepted) { + this.phase = 'running'; + if (this.draft === submitted) this.draft = ''; + } + + if (next.outcome === 'failed') { + this.draft ||= submitted; + if (!next.accepted) { + this.failure = { message: next.rejection!, kind: 'failed', recovery: 'send' }; + } + } else if (next.outcome === 'unknown') { + this.draft ||= submitted; + this.uncertainty = UNKNOWN_OUTCOME; + } + + if (!previous.accepted && next.accepted) this.history.acceptTurn(content, next); + else this.history.updateTurn(next); + }; + + try { + for await (const event of this.source.run(conversation.id, content, controller.signal)) { + if (this.disposed || this.controller !== controller) return; + + const previous = run.snapshot; + const next = run.receive(event); + if (next !== previous) applyRunTransition(previous, next); + if (next.outcome) break; + } + + if (this.disposed || this.controller !== controller) return; + if (!run.snapshot.outcome) throw new AssistantStreamError(); + } catch (error) { + if (this.disposed || this.controller !== controller) return; + + const previous = run.snapshot; + const next = run.interrupt(error); + if (next !== previous) applyRunTransition(previous, next); + } finally { + if (this.controller === controller) this.controller = undefined; + } + + if (this.disposed) return; + + // The next send must wait for this consumer to release the conversation. + this.historyRefreshPending ||= run.snapshot.outcome === 'completed'; + this.phase = run.snapshot.outcome === 'unknown' ? 'unknown' : 'idle'; + this.publish(); + + if (run.snapshot.accepted) this.activity(); + await this.refreshHistory(); + } + + private async createConversation(controller: AbortController): Promise { + try { + const conversation = await this.source.api.createConversation({}); + if (this.disposed || this.controller !== controller) return undefined; + + // A rejected first send must reuse the conversation already created. + this.conversation = conversation; + this.phase = 'pending'; + this.entry = this.project(); + this.confirmed(this, conversation); + return conversation; + } catch (error) { + if (!this.disposed && this.controller === controller) { + this.controller = undefined; + this.phase = 'idle'; + this.failure = { + message: error instanceof Error ? error.message : 'Could not create a conversation.', + kind: 'failed', + recovery: 'send' + }; + this.publish(); + } + return undefined; + } + } + + async delete(): Promise { + if (this.disposed || !this.conversation || this.deleting) return { status: 'ignored' }; + + this.deleting = true; + this.publish(); + + try { + await this.source.api.deleteConversation(this.conversation.id); + if (this.disposed) return { status: 'ignored' }; + + this.dispose(); + return { status: 'deleted' }; + } catch (error) { + if (this.disposed) return { status: 'ignored' }; + + const message = error instanceof Error ? error.message : 'Could not delete the conversation.'; + this.deleting = false; + this.failure = { message, kind: 'failed' }; + this.publish(); + void this.refreshHistory(); + + return { status: 'failed', message }; + } + } + + dispose(): void { + this.disposed = true; + this.history.dispose(); + this.controller?.abort(); + this.controller = undefined; + } +} diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantDataSource.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantDataSource.ts new file mode 100644 index 00000000000..e6b1fb91d0d --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantDataSource.ts @@ -0,0 +1,20 @@ +/* + * 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 { AssistantConversationApi, AssistantRunEvent } from '@/entities/assistant/model/assistantContract'; + +/** A note-scoped source. Replacing it resets the panel's conversations and drafts. */ +export interface AssistantDataSource { + api: AssistantConversationApi; + /** Yields one submission's events; abort stops local consumption, not a server run. */ + run: (conversationId: string, content: string, signal: AbortSignal) => AsyncIterable; +} diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantHistory.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantHistory.ts new file mode 100644 index 00000000000..37769baa08a --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantHistory.ts @@ -0,0 +1,354 @@ +/* + * 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 { + AssistantConversationApi, + AssistantAnswerMessage, + AssistantMessage, + AssistantMessagePage +} from '@/entities/assistant/model/assistantContract'; +import { joinPages, mergeAnswers } from '@/entities/assistant/model/messageHistory'; +import type { AssistantAnswerView } from '@/entities/assistant/ui/answerView'; +import type { AssistantPanelHistory, AssistantRecovery } from './assistantPanelState'; +import type { AssistantRunSnapshot } from './assistantRun'; + +const reconcileMessageKeys = ( + entry: AssistantPanelHistory, + messages: AssistantMessage[], + localAnswer?: AssistantAnswerMessage +) => { + const turns = new Map(); + const rendered = localAnswer ? [...entry.messages, localAnswer] : entry.messages; + + for (let index = 0; index < rendered.length; index++) { + const message = rendered[index]; + if (message.role !== 'assistant') continue; + + const turn = { + answerKey: + (message === localAnswer ? entry.answerKey : undefined) ?? entry.messageKeys.get(message.id) ?? message.id, + question: rendered[index - 1] + }; + for (const segment of message.segments) turns.set(segment.id, turn); + } + + if (!turns.size) return entry.messageKeys; + + const keys = new Map(entry.messageKeys); + for (let index = 0; index < messages.length; index++) { + const message = messages[index]; + if (message.role !== 'assistant') continue; + + const segment = message.segments.find(segment => turns.has(segment.id)); + if (!segment) continue; + + const turn = turns.get(segment.id)!; + keys.set(message.id, turn.answerKey); + + const savedQuestion = messages[index - 1]; + // Identical question text is not enough to identify a turn. + if ( + turn.question?.role === 'user' && + savedQuestion?.role === 'user' && + turn.question.content === savedQuestion.content + ) { + keys.set(savedQuestion.id, entry.messageKeys.get(turn.question.id) ?? turn.question.id); + } + } + + return keys; +}; + +const reconcileLatestHistory = ( + entry: AssistantPanelHistory, + page: AssistantMessagePage, + localAnswer?: AssistantAnswerMessage +): Pick => { + const messageKeys = reconcileMessageKeys(entry, page.messages, localAnswer); + let latest = page.messages; + let cursor = page.cursor; + let prefix: AssistantMessage[] = []; + const first = latest[0]; + + if (entry.loaded && first) { + const firstKey = messageKeys.get(first.id) ?? first.id; + let index = entry.messages.findIndex(message => (messageKeys.get(message.id) ?? message.id) === firstKey); + if (index < 0 && localAnswer && entry.answerKey === firstKey) index = entry.messages.length; + + if (index >= 0) { + prefix = entry.messages.slice(0, index); + const previous = entry.messages[index] ?? localAnswer; + let leading = 0; + + if (first.role === 'assistant' && previous?.role === 'assistant') { + leading = previous.segments.findIndex(segment => segment.id === first.segments[0]?.id); + if (leading > 0) { + latest = [ + mergeAnswers({ ...previous, segments: previous.segments.slice(0, leading) }, first), + ...latest.slice(1) + ]; + } + } + + // Retained older messages still need their original pagination cursor. + if (prefix.length || leading >= 0) cursor = entry.cursor; + } + } + + const localAnswerViews = new Map(); + for (const message of prefix) { + const view = entry.localAnswerViews.get(message.id); + if (view) localAnswerViews.set(message.id, view); + } + + return { + messages: prefix.length ? [...prefix, ...latest] : latest, + messageKeys, + localAnswerViews, + cursor + }; +}; + +type HistoryLoadResult = + | { status: 'committed'; version: number } + | { status: 'ignored' } + | { status: 'failed'; version: number; error: unknown }; + +interface RetainedTurn { + key: string; + questionKey: string; + view?: AssistantAnswerView; + message?: AssistantAnswerMessage; + recovery?: AssistantRecovery; +} + +/** Restores saved history without replacing the current failed turn's runtime error and partial answer. */ +const retainFailedTurn = ( + entry: AssistantPanelHistory, + messages: AssistantMessage[], + keys: Map, + turn?: RetainedTurn +): Pick => { + if (turn?.recovery !== 'send') return { messages, messageKeys: keys }; + + const question = entry.messages.find( + message => message.role === 'user' && (entry.messageKeys.get(message.id) ?? message.id) === turn.questionKey + ); + if (!question || question.role !== 'user') return { messages, messageKeys: keys }; + + const messageKeys = new Map(keys); + let savedQuestion = + messages.find( + message => + message.role === 'user' && message.id !== question.id && messageKeys.get(message.id) === turn.questionKey + ) ?? messages.find(message => message.id === question.id); + + // The fixture has one writer: only this failed turn's trailing question may match without a shared user ID. + const trailing = messages.at(-1); + if (!savedQuestion && trailing?.role === 'user' && trailing.content === question.content) savedQuestion = trailing; + if (savedQuestion) messageKeys.set(savedQuestion.id, turn.questionKey); + + const retained = messages.filter( + message => + message.role !== 'user' || + !savedQuestion || + message.id === savedQuestion.id || + (messageKeys.get(message.id) ?? message.id) !== turn.questionKey + ); + if (!savedQuestion) retained.push(question); + + return { messages: retained, messageKeys }; +}; + +export class AssistantHistory { + private messages: AssistantMessage[] = []; + private messageKeys = new Map(); + private localAnswerViews = new Map(); + private cursor: string | null = null; + private loaded: boolean; + + private requestStatus: 'idle' | 'latest' | 'earlier' | 'failed' = 'idle'; + private requestVersion = 0; + private consumedCursors = new Set(); + + private turnSequence = 0; + private turn?: RetainedTurn; + + private disposed = false; + + constructor( + private readonly key: string, + hasConversation: boolean, + private readonly changed: () => void + ) { + this.loaded = !hasConversation; + } + + get snapshot(): AssistantPanelHistory { + const failedKey = this.turn?.recovery === 'send' ? this.turn.key : undefined; + + return { + // Keep saved answer boundaries for pagination, but render the retained failed answer only once. + messages: failedKey + ? this.messages.filter( + message => message.role !== 'assistant' || this.messageKeys.get(message.id) !== failedKey + ) + : this.messages, + messageKeys: this.messageKeys, + localAnswerViews: this.localAnswerViews, + cursor: this.cursor, + loaded: this.loaded, + loading: this.requestStatus === 'latest', + loadingEarlier: this.requestStatus === 'earlier', + answer: this.turn?.view, + answerKey: this.turn?.key, + answerRecovery: this.turn?.recovery + }; + } + + /** Starts a new turn and retains any preceding unreconciled answer. */ + beginTurn(): string { + this.invalidateRequests(); + this.promoteTurn(); + + const sequence = ++this.turnSequence; + this.turn = { + key: `local-answer:${this.key}:${sequence}`, + questionKey: `local-question:${this.key}:${sequence}` + }; + + this.changed(); + return this.turn.key; + } + + private promoteTurn(): void { + if (!this.turn?.message) return; + + const { message, key, view } = this.turn; + const saved = this.messages.findIndex( + current => current.role === 'assistant' && this.messageKeys.get(current.id) === key + ); + this.messages = + saved < 0 + ? [...this.messages, message] + : this.messages.map((current, index) => (index === saved ? message : current)); + this.messageKeys = new Map(this.messageKeys).set(message.id, key); + if (view) this.localAnswerViews = new Map(this.localAnswerViews).set(message.id, view); + } + + acceptTurn(content: string, run: AssistantRunSnapshot): void { + if (!this.turn || this.disposed) return; + + this.messages = [...this.messages, { id: this.turn.questionKey, role: 'user', content }]; + this.updateTurn(run); + } + + updateTurn(run: AssistantRunSnapshot): void { + if (!this.turn || this.disposed) return; + + this.turn = { + ...this.turn, + view: run.view, + message: run.message, + recovery: run.outcome === 'failed' && run.accepted ? 'send' : run.outcome === 'unknown' ? 'reload' : undefined + }; + this.changed(); + } + + private invalidateRequests(): void { + ++this.requestVersion; + this.requestStatus = 'idle'; + } + + async loadLatest(api: AssistantConversationApi, conversationId: string): Promise { + if (this.disposed) return { status: 'ignored' }; + + const version = ++this.requestVersion; + this.requestStatus = 'latest'; + this.changed(); + + try { + const page = await api.getMessages(conversationId); + if (!this.current(version)) return { status: 'ignored' }; + + const reconciled = reconcileLatestHistory(this.snapshot, page, this.turn?.message); + const retained = retainFailedTurn(this.snapshot, reconciled.messages, reconciled.messageKeys, this.turn); + this.messages = retained.messages; + this.messageKeys = retained.messageKeys; + this.localAnswerViews = reconciled.localAnswerViews; + this.cursor = reconciled.cursor; + if (this.turn?.recovery !== 'send') this.turn = undefined; + this.loaded = true; + this.requestStatus = 'idle'; + this.consumedCursors.clear(); + + this.changed(); + return { status: 'committed', version }; + } catch (error) { + if (!this.current(version)) return { status: 'ignored' }; + + this.requestStatus = 'failed'; + this.changed(); + return { status: 'failed', version, error }; + } + } + + get canLoadEarlier(): boolean { + return !this.disposed && !!this.cursor && this.requestStatus === 'idle' && !this.consumedCursors.has(this.cursor); + } + + async loadEarlier(api: AssistantConversationApi, conversationId: string): Promise { + const cursor = this.cursor; + if (!this.canLoadEarlier || !cursor) return { status: 'ignored' }; + + const version = ++this.requestVersion; + this.requestStatus = 'earlier'; + this.changed(); + + try { + const page = await api.getMessages(conversationId, cursor); + if (!this.current(version)) return { status: 'ignored' }; + + const messages = joinPages(page.messages, this.messages); + const keys = reconcileMessageKeys(this.snapshot, messages, this.turn?.message); + const retained = retainFailedTurn(this.snapshot, messages, keys, this.turn); + this.messages = retained.messages; + this.messageKeys = retained.messageKeys; + this.cursor = page.cursor; + this.requestStatus = 'idle'; + this.consumedCursors.add(cursor); + + this.changed(); + return { status: 'committed', version }; + } catch (error) { + if (!this.current(version)) return { status: 'ignored' }; + + this.requestStatus = 'failed'; + this.changed(); + return { status: 'failed', version, error }; + } + } + + /** Whether this result is still valid for the current history. */ + isCurrent(result: HistoryLoadResult): boolean { + return result.status !== 'ignored' && this.current(result.version); + } + + private current(version: number): boolean { + return !this.disposed && this.requestVersion === version; + } + + dispose(): void { + this.disposed = true; + this.invalidateRequests(); + } +} diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantPanelModel.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantPanelModel.ts new file mode 100644 index 00000000000..af562c431ce --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantPanelModel.ts @@ -0,0 +1,248 @@ +/* + * 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 '@/entities/assistant/model/assistantContract'; +import { AssistantConversationSession } from './assistantConversation'; +import type { AssistantDataSource } from './assistantDataSource'; +import type { AssistantDeletionResult, AssistantPanel } from './assistantPanelState'; + +export class AssistantPanelModel { + private conversations: AssistantConversation[] = []; + private listLoading = true; + private listError?: string; + + private selectedKey = 'draft:0'; + private draftKey: string | undefined = 'draft:0'; + private draftSequence = 0; + + private readonly sessions = new Map(); + private readonly deleted = new Set(); + + private readonly listeners = new Set<() => void>(); + private active = false; + private generation = 0; + private listVersion = 0; + private snapshot: AssistantPanel; + + constructor(private readonly source: AssistantDataSource) { + this.createSession(this.selectedKey); + this.snapshot = this.project(); + } + + readonly getSnapshot = (): AssistantPanel => this.snapshot; + + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + start(): void { + if (this.active) return; + + this.active = true; + const generation = ++this.generation; + + this.sessions.forEach(session => session.dispose()); + this.sessions.clear(); + this.deleted.clear(); + + this.conversations = []; + this.listLoading = true; + this.listError = undefined; + this.selectedKey = 'draft:0'; + this.draftKey = 'draft:0'; + this.draftSequence = 0; + + this.createSession(this.selectedKey); + this.publish(); + + void this.loadList(generation); + } + + private async loadList(generation: number): Promise { + const version = ++this.listVersion; + try { + const conversations = await this.source.api.listConversations(); + if (!this.active || this.generation !== generation || this.listVersion !== version) return; + + const visible = conversations.filter(conversation => !this.deleted.has(conversation.id)); + const refreshed = new Map(visible.map(conversation => [conversation.id, conversation])); + this.conversations = [ + ...this.conversations.filter(conversation => !refreshed.has(conversation.id)), + ...visible + ].sort((a, b) => (Date.parse(b.updatedAt) || 0) - (Date.parse(a.updatedAt) || 0)); + + this.sessions.forEach(session => { + const id = session.snapshot.conversation?.id; + const conversation = id && refreshed.get(id); + if (conversation) session.updateConversation(conversation); + }); + + this.listLoading = false; + this.listError = undefined; + } catch (error) { + if (!this.active || this.generation !== generation || this.listVersion !== version) return; + + this.listLoading = false; + this.listError = error instanceof Error ? error.message : 'Could not load conversations.'; + } + + this.publish(); + } + + private createSession(key: string, conversation?: AssistantConversation): AssistantConversationSession { + const session = new AssistantConversationSession( + key, + conversation, + this.source, + this.publish, + this.confirmed, + this.refreshConversations + ); + this.sessions.set(key, session); + return session; + } + + private readonly refreshConversations = (): void => { + if (this.active) void this.loadList(this.generation); + }; + + private readonly confirmed = (session: AssistantConversationSession, conversation: AssistantConversation): void => { + if (!this.active || this.sessions.get(session.key) !== session) return; + + if (this.draftKey === session.key) this.draftKey = undefined; + this.conversations = [conversation, ...this.conversations.filter(item => item.id !== conversation.id)]; + this.publish(); + }; + + readonly select = (id: string): void => { + if (!this.active) return; + + const conversation = this.conversations.find(item => item.id === id); + if (!conversation) return; + + const session = + Array.from(this.sessions.values()).find(item => item.snapshot.conversation?.id === id) ?? + this.createSession(`conversation:${id}`, conversation); + + this.selectedKey = session.key; + this.publish(); + + session.ensureHistory(); + }; + + private selectDraft(): void { + if (!this.draftKey) { + this.draftKey = `draft:${++this.draftSequence}`; + this.createSession(this.draftKey); + } + + this.selectedKey = this.draftKey; + } + + readonly newConversation = (): void => { + if (!this.active || (this.draftKey && this.sessions.get(this.draftKey)!.snapshot.phase === 'creating')) return; + + this.selectDraft(); + this.publish(); + }; + + readonly setDraft = (value: string): void => { + if (!this.active) return; + + this.sessions.get(this.selectedKey)!.setDraft(value); + }; + + readonly send = (question?: string): Promise => { + if (!this.active) return Promise.resolve(); + + return this.sessions.get(this.selectedKey)!.send(question); + }; + + readonly reload = (): Promise => { + if (!this.active) return Promise.resolve(); + + return this.sessions.get(this.selectedKey)!.reload(); + }; + + readonly loadEarlier = (): Promise => { + if (!this.active) return Promise.resolve(false); + + return this.sessions.get(this.selectedKey)!.loadEarlier(); + }; + + readonly deleteConversation = async (id: string): Promise => { + if (!this.active) return { status: 'ignored' }; + + const conversation = this.conversations.find(item => item.id === id); + if (!conversation) return { status: 'ignored' }; + + const session = + Array.from(this.sessions.values()).find(item => item.snapshot.conversation?.id === id) ?? + this.createSession(`conversation:${id}`, conversation); + if (session.snapshot.deleting) return { status: 'ignored' }; + + const generation = this.generation; + const result = await session.delete(); + + if (!this.active || this.generation !== generation || this.sessions.get(session.key) !== session) { + return { status: 'ignored' }; + } + if (result.status !== 'deleted') return result; + + this.deleted.add(id); + this.sessions.delete(session.key); + this.conversations = this.conversations.filter(item => item.id !== id); + if (this.selectedKey === session.key) this.selectDraft(); + this.publish(); + return result; + }; + + private project(): AssistantPanel { + const deletingConversationIds: string[] = []; + this.sessions.forEach(session => { + const entry = session.snapshot; + if (entry.deleting && entry.conversation) deletingConversationIds.push(entry.conversation.id); + }); + + return { + conversations: this.conversations, + listLoading: this.listLoading, + listError: this.listError, + selectedKey: this.selectedKey, + entry: this.sessions.get(this.selectedKey)!.snapshot, + creatingConversation: !!this.draftKey && this.sessions.get(this.draftKey)!.snapshot.phase === 'creating', + deletingConversationIds, + select: this.select, + newConversation: this.newConversation, + setDraft: this.setDraft, + send: this.send, + reload: this.reload, + loadEarlier: this.loadEarlier, + deleteConversation: this.deleteConversation + }; + } + + private readonly publish = (): void => { + if (!this.active) return; + + this.snapshot = this.project(); + this.listeners.forEach(listener => listener()); + }; + + dispose(): void { + this.active = false; + ++this.generation; + + this.sessions.forEach(session => session.dispose()); + } +} diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantPanelState.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantPanelState.ts new file mode 100644 index 00000000000..9fe377fe939 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantPanelState.ts @@ -0,0 +1,87 @@ +/* + * 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, AssistantMessage } from '@/entities/assistant/model/assistantContract'; +import type { AssistantAnswerErrorKind, AssistantAnswerView } from '@/entities/assistant/ui/answerView'; + +export type AssistantPanelPhase = 'idle' | 'creating' | 'pending' | 'running' | 'unknown'; +export type AssistantRecovery = 'send' | 'reload'; + +export interface AssistantPanelFailure { + message: string; + kind: AssistantAnswerErrorKind; + recovery?: AssistantRecovery; +} + +export type AssistantDeletionResult = + { status: 'deleted' } | { status: 'ignored' } | { status: 'failed'; message: string }; + +export interface AssistantPanelHistory { + messages: AssistantMessage[]; + /** Message IDs mapped to stable rendered-row keys. */ + messageKeys: Map; + /** Display states retained until their answers are reconciled with history. */ + localAnswerViews: Map; + + cursor: string | null; + loaded: boolean; + loading: boolean; + loadingEarlier: boolean; + + answer?: AssistantAnswerView; + answerKey?: string; + answerRecovery?: AssistantRecovery; +} + +export interface AssistantPanelEntry extends AssistantPanelHistory { + /** Stable session key, including after conversation creation. */ + key: string; + conversation?: AssistantConversation; + draft: string; + + phase: AssistantPanelPhase; + /** Failure displayed outside an answer. */ + failure?: AssistantPanelFailure; + uncertainty?: string; + submittedQuestion?: string; + + deleting: boolean; +} + +export interface AssistantPanelSnapshot { + conversations: AssistantConversation[]; + listLoading: boolean; + listError?: string; + + selectedKey: string; + entry: AssistantPanelEntry; + + deletingConversationIds: string[]; + /** Whether the unconfirmed draft is being created, even when another conversation is selected. */ + creatingConversation: boolean; +} + +export interface AssistantPanelActions { + select(id: string): void; + newConversation(): void; + + setDraft(value: string): void; + send(question?: string): Promise; + + reload(): Promise; + /** Resolves to true only when an earlier page is applied to the current history. */ + loadEarlier(): Promise; + + deleteConversation(id: string): Promise; +} + +export interface AssistantPanel extends AssistantPanelSnapshot, AssistantPanelActions {} diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantRun.spec.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantRun.spec.ts new file mode 100644 index 00000000000..4cfdd3d9432 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantRun.spec.ts @@ -0,0 +1,319 @@ +/* + * 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 { describe, expect, it } from 'vitest'; + +import { + AssistantConnectionError, + AssistantStreamError, + type AssistantRunEvent +} from '@/entities/assistant/model/assistantContract'; +import { answerText, answerToolCalls } from '@/entities/assistant/model/messageHistory'; +import { AssistantRun } from './assistantRun'; + +describe('AssistantRun', () => { + it('ignores text and tools before acceptance without publishing an answer', () => { + const run = new AssistantRun('local-answer'); + const initial = run.snapshot; + const events: AssistantRunEvent[] = [ + { type: 'message.delta', messageId: 'ignored', delta: 'Not this run' }, + { type: 'message.done', messageId: 'ignored', content: 'Still not this run' }, + { type: 'tool_call.started', toolCallId: 'ignored-tool', name: 'read' }, + { type: 'tool_call.done', toolCallId: 'ignored-tool' } + ]; + + expect(initial).toEqual({ accepted: false }); + + for (const event of events) { + const ignored = run.receive(event); + + expect(ignored).toBe(initial); + } + + const started = run.receive({ type: 'run.started' }); + + expect(started).toEqual({ accepted: true, view: { status: 'loading' } }); + + const completed = run.receive({ type: 'run.completed' }); + + expect(completed.message).toEqual({ id: 'local-answer', role: 'assistant', segments: [] }); + expect(initial).toEqual({ accepted: false }); + }); + + it('appends deltas and replaces only the matching round while keeping all rounds in order', () => { + const run = new AssistantRun('local-answer'); + + const loading = run.receive({ type: 'run.started' }); + const partial = run.receive({ type: 'message.delta', messageId: 'first', delta: 'Draft' }); + const appended = run.receive({ type: 'message.delta', messageId: 'first', delta: ' text' }); + + expect(appended.view).toEqual({ + status: 'streaming', + content: 'Draft text', + tools: [] + }); + + run.receive({ type: 'message.done', messageId: 'first', content: 'First answer' }); + run.receive({ type: 'message.done', messageId: 'second', content: 'Second answer' }); + const revised = run.receive({ type: 'message.done', messageId: 'first', content: 'Revised first answer' }); + + expect(revised.view).toEqual({ + status: 'streaming', + content: 'Revised first answer\n\nSecond answer', + tools: [] + }); + expect(revised.outcome).toBeUndefined(); + expect(revised.message).toBeUndefined(); + + const duplicateStart = run.receive({ type: 'run.started' }); + + expect(duplicateStart).toBe(revised); + + const completed = run.receive({ type: 'run.completed' }); + + expect(completed).toMatchObject({ accepted: true, outcome: 'completed', view: { status: 'done' } }); + expect(completed.message).toMatchObject({ + id: 'second', + role: 'assistant', + segments: [ + { id: 'first', content: 'Revised first answer' }, + { id: 'second', content: 'Second answer' } + ] + }); + expect(completed.view).toEqual({ status: 'done', message: completed.message }); + expect(loading).toEqual({ accepted: true, view: { status: 'loading' } }); + expect(partial.view).toEqual({ status: 'streaming', content: 'Draft', tools: [] }); + }); + + it('associates tools before text with the first round and retains a represented tool-only round', () => { + const run = new AssistantRun('local-answer'); + + run.receive({ type: 'run.started' }); + run.receive({ type: 'tool_call.started', toolCallId: 'before-text', name: 'inspect' }); + run.receive({ type: 'tool_call.done', toolCallId: 'before-text' }); + run.receive({ type: 'message.done', messageId: 'tool-only', content: '' }); + run.receive({ type: 'tool_call.started', toolCallId: 'second-tool', name: 'read' }); + run.receive({ type: 'tool_call.started', toolCallId: 'second-tool', name: 'read' }); + run.receive({ type: 'tool_call.done', toolCallId: 'second-tool' }); + run.receive({ type: 'message.done', messageId: 'answer', content: 'The result' }); + run.receive({ type: 'tool_call.started', toolCallId: 'last-tool', name: 'summarize' }); + const completed = run.receive({ type: 'run.completed' }); + + expect(completed.message).toEqual({ + id: 'answer', + role: 'assistant', + segments: [ + { + id: 'tool-only', + content: '', + toolCalls: [ + { id: 'before-text', name: 'inspect' }, + { id: 'second-tool', name: 'read' } + ] + }, + { id: 'answer', content: 'The result', toolCalls: [{ id: 'last-tool', name: 'summarize' }] } + ] + }); + expect(answerText(completed.message!)).toBe('The result'); + expect(answerToolCalls(completed.message!)).toEqual([ + { id: 'before-text', name: 'inspect' }, + { id: 'second-tool', name: 'read' }, + { id: 'last-tool', name: 'summarize' } + ]); + }); + + it('copies live tool activity so later events never change a published view', () => { + const run = new AssistantRun('local-answer'); + run.receive({ type: 'run.started' }); + + const unnamed = run.receive({ type: 'tool_call.done', toolCallId: 'tool' }); + const running = run.receive({ type: 'tool_call.started', toolCallId: 'tool', name: 'read' }); + const done = run.receive({ type: 'tool_call.done', toolCallId: 'tool' }); + + expect(unnamed.view).toEqual({ + status: 'streaming', + content: '', + tools: [{ id: 'tool', status: 'done' }] + }); + expect(running.view).toEqual({ + status: 'streaming', + content: '', + tools: [{ id: 'tool', name: 'read', status: 'running' }] + }); + expect(done.view).toEqual({ + status: 'streaming', + content: '', + tools: [{ id: 'tool', name: 'read', status: 'done' }] + }); + expect(run.snapshot).toBe(done); + expect(done.message).toBeUndefined(); + }); + + it('accepts completion without a start event and uses the constructor id for an empty answer', () => { + const run = new AssistantRun('fallback-answer'); + + const completed = run.receive({ type: 'run.completed' }); + + expect(completed).toEqual({ + accepted: true, + outcome: 'completed', + view: { status: 'done', message: { id: 'fallback-answer', role: 'assistant', segments: [] } }, + message: { id: 'fallback-answer', role: 'assistant', segments: [] } + }); + }); + + it('does not invent a segment for tools when no round was represented', () => { + const run = new AssistantRun('fallback-answer'); + run.receive({ type: 'run.started' }); + + run.receive({ type: 'tool_call.started', toolCallId: 'tool', name: 'read' }); + const completed = run.receive({ type: 'run.completed' }); + + expect(completed.message).toEqual({ id: 'fallback-answer', role: 'assistant', segments: [] }); + }); + + it('reports an unaccepted failure as a rejection without an answer', () => { + const run = new AssistantRun('local-answer'); + + const rejected = run.receive({ type: 'run.failed', message: 'Permission denied' }); + + expect(rejected).toEqual({ + accepted: false, + outcome: 'failed', + rejection: 'Permission denied' + }); + }); + + it('retains accepted partial content and tools on a reported failure', () => { + const run = new AssistantRun('local-answer'); + run.receive({ type: 'run.started' }); + run.receive({ type: 'message.delta', messageId: 'partial', delta: 'Partial answer' }); + run.receive({ type: 'tool_call.started', toolCallId: 'tool', name: 'read' }); + + const failed = run.receive({ type: 'run.failed', message: 'The run failed' }); + + expect(failed).toEqual({ + accepted: true, + outcome: 'failed', + view: { + status: 'error', + kind: 'failed', + message: 'The run failed', + content: 'Partial answer', + tools: [{ id: 'tool', name: 'read', status: 'running' }] + }, + message: { + id: 'partial', + role: 'assistant', + segments: [{ id: 'partial', content: 'Partial answer', toolCalls: [{ id: 'tool', name: 'read' }] }] + } + }); + expect(failed.rejection).toBeUndefined(); + }); + + it.each([ + { error: new AssistantConnectionError(), kind: 'connection' }, + { error: new AssistantStreamError(), kind: 'stream' }, + { error: new Error('Interrupted by the source'), kind: 'stream' } + ])('keeps accepted partial output on a $kind interruption', ({ error, kind }) => { + const run = new AssistantRun('local-answer'); + run.receive({ type: 'run.started' }); + run.receive({ type: 'message.done', messageId: 'partial', content: 'Saved portion' }); + run.receive({ type: 'tool_call.started', toolCallId: 'tool', name: 'inspect' }); + + const interrupted = run.interrupt(error); + + expect(interrupted).toMatchObject({ + accepted: true, + outcome: 'unknown', + view: { + status: 'error', + kind, + message: error.message, + content: 'Saved portion', + tools: [{ id: 'tool', name: 'inspect', status: 'running' }] + }, + message: { + id: 'partial', + role: 'assistant', + segments: [{ id: 'partial', content: 'Saved portion', toolCalls: [{ id: 'tool', name: 'inspect' }] }] + } + }); + }); + + it('shows an unknown outcome before acceptance without inventing a raw answer', () => { + const run = new AssistantRun('local-answer'); + const error = new AssistantConnectionError(); + + const interrupted = run.interrupt(error); + + expect(interrupted).toEqual({ + accepted: false, + outcome: 'unknown', + view: { status: 'error', kind: 'connection', message: error.message, content: '', tools: [] } + }); + }); + + it('uses the existing unknown-outcome fallback only for non-Error interruptions', () => { + const run = new AssistantRun('local-answer'); + + const interrupted = run.interrupt('not an Error'); + + expect(interrupted.view).toEqual({ + status: 'error', + kind: 'stream', + message: 'The outcome of this question is unknown.', + content: '', + tools: [] + }); + + const emptyError = new AssistantRun('another-answer'); + + const interruptedEmptyError = emptyError.interrupt(new Error('')); + + expect(interruptedEmptyError.view).toMatchObject({ message: '' }); + }); + + it.each(['completed', 'failed', 'unknown'] as const)('ignores all events and interruptions after %s', outcome => { + const run = new AssistantRun('local-answer'); + run.receive({ type: 'run.started' }); + run.receive({ type: 'message.done', messageId: 'answer', content: 'Original' }); + run.receive({ type: 'tool_call.started', toolCallId: 'tool', name: 'read' }); + const terminal = + outcome === 'unknown' + ? run.interrupt(new AssistantStreamError()) + : run.receive(outcome === 'completed' ? { type: 'run.completed' } : { type: 'run.failed', message: 'Failed' }); + const expected = JSON.stringify(terminal); + const lateEvents: AssistantRunEvent[] = [ + { type: 'run.started' }, + { type: 'message.delta', messageId: 'answer', delta: ' changed' }, + { type: 'message.done', messageId: 'answer', content: 'Replaced' }, + { type: 'message.done', messageId: 'later', content: 'Another round' }, + { type: 'tool_call.started', toolCallId: 'tool', name: 'renamed' }, + { type: 'tool_call.done', toolCallId: 'tool' }, + { type: 'run.completed' }, + { type: 'run.failed', message: 'Late failure' } + ]; + + for (const event of lateEvents) { + const ignored = run.receive(event); + + expect(ignored).toBe(terminal); + } + + const ignoredInterruption = run.interrupt(new AssistantConnectionError()); + + expect(ignoredInterruption).toBe(terminal); + expect(run.snapshot).toBe(terminal); + expect(JSON.stringify(terminal)).toBe(expected); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantRun.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantRun.ts new file mode 100644 index 00000000000..06998abde95 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/assistantRun.ts @@ -0,0 +1,142 @@ +/* + * 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 { + AssistantConnectionError, + type AssistantAnswerMessage, + type AssistantRunEvent, + type AssistantToolCallRef +} from '@/entities/assistant/model/assistantContract'; +import { answerText } from '@/entities/assistant/model/messageHistory'; +import type { AssistantAnswerView, AssistantToolActivity } from '@/entities/assistant/ui/answerView'; + +export interface AssistantRunSnapshot { + accepted: boolean; + outcome?: 'completed' | 'failed' | 'unknown'; + view?: AssistantAnswerView; + message?: AssistantAnswerMessage; + rejection?: string; +} + +/** The accumulated answer and outcome of one submitted question. */ +export class AssistantRun { + private current: AssistantRunSnapshot = { accepted: false }; + private readonly answer: AssistantAnswerMessage; + private readonly tools: AssistantToolActivity[] = []; + private readonly pendingTools: AssistantToolCallRef[] = []; + + constructor(answerId: string) { + this.answer = { id: answerId, role: 'assistant', segments: [] }; + } + + get snapshot(): AssistantRunSnapshot { + return this.current; + } + + receive(event: AssistantRunEvent): AssistantRunSnapshot { + if (this.current.outcome) return this.current; + + switch (event.type) { + case 'run.started': + if (!this.current.accepted) this.current = { accepted: true, view: { status: 'loading' } }; + return this.current; + + case 'run.completed': + this.current = { + accepted: true, + outcome: 'completed', + view: { status: 'done', message: this.answer }, + message: this.answer + }; + return this.current; + + case 'run.failed': + this.current = this.current.accepted + ? { + accepted: true, + outcome: 'failed', + view: { + status: 'error', + kind: 'failed', + message: event.message, + content: answerText(this.answer), + tools: this.toolSnapshot() + }, + message: this.answer + } + : { accepted: false, outcome: 'failed', rejection: event.message }; + return this.current; + } + + if (!this.current.accepted) return this.current; + + switch (event.type) { + case 'message.delta': + case 'message.done': { + let segment = this.answer.segments.find(item => item.id === event.messageId); + if (!segment) { + segment = { id: event.messageId, content: '', toolCalls: this.pendingTools.splice(0) }; + this.answer.segments.push(segment); + this.answer.id = segment.id; + } + // message.done ends a round, not the whole answer. + segment.content = event.type === 'message.done' ? event.content : segment.content + event.delta; + break; + } + + case 'tool_call.started': { + const tool = this.tools.find(item => item.id === event.toolCallId); + if (tool) Object.assign(tool, { name: event.name, status: 'running' }); + else this.tools.push({ id: event.toolCallId, name: event.name, status: 'running' }); + + const calls = this.answer.segments.at(-1)?.toolCalls ?? this.pendingTools; + if (!calls.some(call => call.id === event.toolCallId)) calls.push({ id: event.toolCallId, name: event.name }); + break; + } + + case 'tool_call.done': { + const tool = this.tools.find(item => item.id === event.toolCallId); + if (tool) tool.status = 'done'; + else this.tools.push({ id: event.toolCallId, status: 'done' }); + break; + } + } + + this.current = { + accepted: true, + view: { status: 'streaming', content: answerText(this.answer), tools: this.toolSnapshot() } + }; + return this.current; + } + + interrupt(error: unknown): AssistantRunSnapshot { + if (this.current.outcome) return this.current; + + this.current = { + accepted: this.current.accepted, + outcome: 'unknown', + view: { + status: 'error', + kind: error instanceof AssistantConnectionError ? 'connection' : 'stream', + message: error instanceof Error ? error.message : 'The outcome of this question is unknown.', + content: answerText(this.answer), + tools: this.toolSnapshot() + }, + ...(this.current.accepted ? { message: this.answer } : {}) + }; + return this.current; + } + + private toolSnapshot(): AssistantToolActivity[] { + return this.tools.map(tool => ({ ...tool })); + } +} diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/useAssistantPanel.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/useAssistantPanel.spec.tsx new file mode 100644 index 00000000000..452691f4397 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/useAssistantPanel.spec.tsx @@ -0,0 +1,2094 @@ +/* + * 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 { StrictMode } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { + AssistantConnectionError, + type AssistantConversation, + type AssistantConversationApi, + type AssistantMessagePage, + type AssistantRunEvent +} from '@/entities/assistant/model/assistantContract'; +import type { AssistantDataSource } from './assistantDataSource'; +import { createAssistantFixture } from '../fixtures/assistantFixture'; +import { ASSISTANT_HISTORY_CONVERSATIONS, FIXTURE_NOW } from '../fixtures/assistantFixtureData'; +import { AssistantPanelModel } from './assistantPanelModel'; +import type { AssistantDeletionResult, AssistantPanel } from './assistantPanelState'; +import { useAssistantPanel } from './useAssistantPanel'; + +const conversation = (id: string, canSendMessage = true): AssistantConversation => ({ + id, + title: `Conversation ${id}`, + ownerId: 'fixture-owner', + canSendMessage, + updatedAt: '2026-10-19T00:00:00Z' +}); + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((accept, fail) => { + resolve = accept; + reject = fail; + }); + + return { promise, resolve, reject }; +}; + +/** Allows late events after abort to exercise stale-response handling. */ +class RunFeed implements AsyncIterable { + private queue: (AssistantRunEvent | Error | null)[] = []; + private waiting?: (value: AssistantRunEvent | Error | null) => void; + + push(...items: (AssistantRunEvent | Error | null)[]) { + for (const item of items) { + const waiting = this.waiting; + if (waiting) { + this.waiting = undefined; + waiting(item); + } else { + this.queue.push(item); + } + } + } + + async *[Symbol.asyncIterator]() { + while (true) { + const item = this.queue.length + ? this.queue.shift()! + : await new Promise(resolve => { + this.waiting = resolve; + }); + + if (item instanceof Error) throw item; + if (item === null) return; + yield item; + } + } +} + +const sourceHarness = () => { + const feeds: RunFeed[] = []; + const api = { + listConversations: vi + .fn() + .mockResolvedValue([conversation('a'), conversation('b'), conversation('read-only', false)]), + createConversation: vi + .fn() + .mockResolvedValue(conversation('created')), + deleteConversation: vi.fn().mockResolvedValue(undefined), + getMessages: vi.fn().mockResolvedValue({ messages: [], cursor: null }) + }; + const run = vi.fn((_id: string, _content: string, _signal: AbortSignal) => { + const feed = new RunFeed(); + feeds.push(feed); + return feed; + }); + + const source: AssistantDataSource = { api, run }; + return { source, api, run, feeds }; +}; + +const mountPanel = async (source: AssistantDataSource) => { + const panel = renderHook(() => useAssistantPanel(source)); + await act(async () => {}); + return panel; +}; + +type Panel = { result: { current: AssistantPanel } }; + +const sendQuestion = async (panel: Panel, question?: string) => { + let completion!: Promise; + await act(async () => { + completion = panel.result.current.send(question); + }); + + return { completion }; +}; + +describe('useAssistantPanel', () => { + it('keeps New local and preserves its draft without creating a conversation', async () => { + const harness = sourceHarness(); + const panel = await mountPanel(harness.source); + act(() => panel.result.current.setDraft('Local question')); + const draftKey = panel.result.current.selectedKey; + + await act(async () => panel.result.current.select('a')); + act(() => panel.result.current.setDraft('Question for A')); + act(() => panel.result.current.newConversation()); + act(() => panel.result.current.newConversation()); + + expect(panel.result.current.selectedKey).toBe(draftKey); + expect(panel.result.current.entry.draft).toBe('Local question'); + expect(harness.api.createConversation).not.toHaveBeenCalled(); + expect(harness.run).not.toHaveBeenCalled(); + + await act(async () => panel.result.current.select('a')); + + expect(panel.result.current.entry.draft).toBe('Question for A'); + }); + + it('guards New during creation, permits an existing conversation send, and releases New on confirmation', async () => { + const harness = sourceHarness(); + const creation = deferred(); + harness.api.createConversation.mockImplementationOnce(() => creation.promise); + const panel = await mountPanel(harness.source); + const draftKey = panel.result.current.selectedKey; + + expect(draftKey).toBe('draft:0'); + + const draftSend = await sendQuestion(panel, 'Draft A'); + + expect(panel.result.current.creatingConversation).toBe(true); + + act(() => panel.result.current.newConversation()); + + expect(panel.result.current.selectedKey).toBe(draftKey); + + await act(async () => panel.result.current.select('b')); + const selectedKey = panel.result.current.selectedKey; + + act(() => panel.result.current.newConversation()); + + expect(panel.result.current.selectedKey).toBe(selectedKey); + + const existingSend = await sendQuestion(panel, 'Existing B'); + + expect(harness.run).toHaveBeenCalledWith('b', 'Existing B', expect.any(AbortSignal)); + + await act(async () => creation.resolve(conversation('confirmed'))); + + expect(panel.result.current.selectedKey).toBe(selectedKey); + expect(panel.result.current.creatingConversation).toBe(false); + + act(() => panel.result.current.newConversation()); + + const nextDraftKey = panel.result.current.selectedKey; + expect(nextDraftKey).not.toBe(draftKey); + expect(panel.result.current.entry).toMatchObject({ draft: '', phase: 'idle' }); + + await act(async () => panel.result.current.select('confirmed')); + + expect(panel.result.current.entry).toMatchObject({ key: draftKey, phase: 'pending' }); + + act(() => panel.result.current.newConversation()); + + expect(panel.result.current.selectedKey).toBe(nextDraftKey); + + await act(async () => { + harness.feeds[0].push({ type: 'run.failed', message: 'B stopped' }); + harness.feeds[1].push({ type: 'run.failed', message: 'A stopped' }); + await Promise.all([draftSend.completion, existingSend.completion]); + }); + + expect(panel.result.current.selectedKey).toBe(nextDraftKey); + expect(harness.api.createConversation).toHaveBeenCalledTimes(1); + }); + + it('keeps the failed creation in its one draft slot without stealing another selection', async () => { + const harness = sourceHarness(); + const creation = deferred(); + harness.api.createConversation.mockImplementationOnce(() => creation.promise); + const panel = await mountPanel(harness.source); + const draftKey = panel.result.current.selectedKey; + + const sending = await sendQuestion(panel, 'Keep draft A'); + await act(async () => panel.result.current.select('b')); + act(() => panel.result.current.setDraft('Keep B too')); + const selectedKey = panel.result.current.selectedKey; + + await act(async () => { + creation.reject(new Error('Creation unavailable')); + await sending.completion; + }); + + expect(panel.result.current.selectedKey).toBe(selectedKey); + expect(panel.result.current.entry.draft).toBe('Keep B too'); + expect(panel.result.current.creatingConversation).toBe(false); + + act(() => panel.result.current.newConversation()); + + expect(panel.result.current.entry).toMatchObject({ + key: draftKey, + draft: 'Keep draft A', + phase: 'idle', + failure: { message: 'Creation unavailable', kind: 'failed', recovery: 'send' } + }); + + act(() => panel.result.current.newConversation()); + + expect(panel.result.current.selectedKey).toBe(draftKey); + expect(harness.api.createConversation).toHaveBeenCalledTimes(1); + expect(harness.run).not.toHaveBeenCalled(); + }); + + it('uses the same creating draft as the selected deletion fallback without allocating another slot', async () => { + const harness = sourceHarness(); + const creation = deferred(); + harness.api.createConversation.mockImplementationOnce(() => creation.promise); + const panel = await mountPanel(harness.source); + const draftKey = panel.result.current.selectedKey; + + const sending = await sendQuestion(panel, 'Still creating'); + await act(async () => panel.result.current.select('a')); + + await act(async () => { + const deleted = await panel.result.current.deleteConversation('a'); + + expect(deleted).toEqual({ status: 'deleted' }); + }); + + expect(panel.result.current.entry).toMatchObject({ key: draftKey, phase: 'creating', draft: 'Still creating' }); + expect(panel.result.current.creatingConversation).toBe(true); + + act(() => panel.result.current.newConversation()); + + expect(panel.result.current.selectedKey).toBe(draftKey); + + await act(async () => { + creation.reject(new Error('Creation unavailable')); + await sending.completion; + }); + + await act(async () => panel.result.current.select('b')); + await act(async () => { + const deleted = await panel.result.current.deleteConversation('b'); + + expect(deleted).toEqual({ status: 'deleted' }); + }); + + expect(panel.result.current.selectedKey).toBe(draftKey); + expect(panel.result.current.entry.draft).toBe('Still creating'); + expect(harness.api.createConversation).toHaveBeenCalledTimes(1); + }); + + it('allocates one fallback draft after deleting the selected confirmed conversation when its slot is empty', async () => { + const harness = sourceHarness(); + const panel = await mountPanel(harness.source); + const confirmedKey = panel.result.current.selectedKey; + + const sending = await sendQuestion(panel, 'Confirm this draft'); + await act(async () => { + harness.feeds[0].push({ type: 'run.failed', message: 'Question rejected' }); + await sending.completion; + }); + + await act(async () => { + const deleted = await panel.result.current.deleteConversation('created'); + + expect(deleted).toEqual({ status: 'deleted' }); + }); + + const fallbackKey = panel.result.current.selectedKey; + expect(fallbackKey).not.toBe(confirmedKey); + expect(panel.result.current.entry).toMatchObject({ phase: 'idle', draft: '' }); + expect(panel.result.current.entry.conversation).toBeUndefined(); + + await act(async () => panel.result.current.select('a')); + act(() => panel.result.current.newConversation()); + + expect(panel.result.current.selectedKey).toBe(fallbackKey); + expect(harness.api.createConversation).toHaveBeenCalledTimes(1); + }); + + it('retains the draft and exposes a create failure without attempting a run', async () => { + const harness = sourceHarness(); + harness.api.createConversation.mockRejectedValueOnce(new Error('Creation unavailable')); + const panel = await mountPanel(harness.source); + act(() => panel.result.current.setDraft('Keep this question')); + + await act(async () => panel.result.current.send()); + + expect(panel.result.current.entry).toMatchObject({ + draft: 'Keep this question', + phase: 'idle', + failure: { message: 'Creation unavailable', kind: 'failed', recovery: 'send' } + }); + expect(panel.result.current.entry.conversation).toBeUndefined(); + expect(harness.run).not.toHaveBeenCalled(); + + await act(async () => panel.result.current.send(' ')); + + expect(harness.api.createConversation).toHaveBeenCalledTimes(1); + }); + + it('saves the confirmed id before rejection and retries that same conversation', async () => { + const harness = sourceHarness(); + const panel = await mountPanel(harness.source); + const draftKey = panel.result.current.selectedKey; + + const first = await sendQuestion(panel, 'Try this question'); + await act(async () => { + harness.feeds[0].push({ type: 'run.failed', message: 'Already running' }); + await first.completion; + }); + + expect(panel.result.current.entry).toMatchObject({ + key: draftKey, + conversation: { id: 'created' }, + draft: 'Try this question', + phase: 'idle', + failure: { message: 'Already running', kind: 'failed', recovery: 'send' } + }); + expect(panel.result.current.entry.messages).toEqual([]); + + const retry = await sendQuestion(panel); + + expect(harness.api.createConversation).toHaveBeenCalledTimes(1); + expect(harness.run.mock.calls.map(([id]) => id)).toEqual(['created', 'created']); + + await act(async () => { + harness.feeds[1].push({ type: 'run.started' }, { type: 'run.failed', message: 'Run stopped' }); + await retry.completion; + }); + + expect(panel.result.current.entry.messages).toHaveLength(1); + expect(panel.result.current.entry.draft).toBe('Try this question'); + }); + + it('prevents duplicate submits while creating, pending, or running in the same conversation', async () => { + const harness = sourceHarness(); + const creation = deferred(); + harness.api.createConversation.mockImplementationOnce(() => creation.promise); + const panel = await mountPanel(harness.source); + + const sending = await sendQuestion(panel, 'One question'); + + expect(panel.result.current.entry.phase).toBe('creating'); + + await act(async () => panel.result.current.send('Duplicate while creating')); + + expect(harness.api.createConversation).toHaveBeenCalledTimes(1); + expect(harness.run).not.toHaveBeenCalled(); + + await act(async () => creation.resolve(conversation('confirmed'))); + + expect(panel.result.current.entry.phase).toBe('pending'); + + await act(async () => panel.result.current.send('Duplicate while pending')); + + expect(harness.run).toHaveBeenCalledTimes(1); + + await act(async () => harness.feeds[0].push({ type: 'run.started' })); + + expect(panel.result.current.entry.phase).toBe('running'); + + await act(async () => panel.result.current.send('Duplicate while running')); + + expect(harness.run).toHaveBeenCalledTimes(1); + + await act(async () => { + harness.feeds[0].push({ type: 'run.failed', message: 'Stopped' }); + await sending.completion; + }); + + expect(panel.result.current.entry).toMatchObject({ phase: 'idle', draft: 'One question' }); + }); + + it('keeps late A events in A and returns to its existing consumer without resending or reloading', async () => { + const harness = sourceHarness(); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + + const sending = await sendQuestion(panel, 'A question'); + await act(async () => harness.feeds[0].push({ type: 'run.started' })); + await act(async () => panel.result.current.select('b')); + act(() => panel.result.current.setDraft('B draft')); + const selectedB = panel.result.current.entry; + + await act(async () => harness.feeds[0].push({ type: 'message.delta', messageId: 'a-answer', delta: 'Only A' })); + + expect(panel.result.current.entry).toBe(selectedB); + expect(panel.result.current.entry).toMatchObject({ conversation: { id: 'b' }, draft: 'B draft', messages: [] }); + expect(panel.result.current.entry.answer).toBeUndefined(); + + const loads = harness.api.getMessages.mock.calls.length; + await act(async () => panel.result.current.select('a')); + + expect(panel.result.current.entry.answer).toMatchObject({ status: 'streaming', content: 'Only A' }); + expect(harness.run).toHaveBeenCalledTimes(1); + expect(harness.api.getMessages).toHaveBeenCalledTimes(loads); + + await act(async () => { + harness.feeds[0].push({ type: 'run.failed', message: 'Stopped' }); + await sending.completion; + }); + }); + + it('lets B run independently while A is active and does not clear a newly edited draft on acceptance', async () => { + const harness = sourceHarness(); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + + const a = await sendQuestion(panel, 'Question A'); + act(() => panel.result.current.setDraft('Next question A')); + await act(async () => harness.feeds[0].push({ type: 'run.started' })); + + expect(panel.result.current.entry.draft).toBe('Next question A'); + + await act(async () => panel.result.current.select('b')); + const b = await sendQuestion(panel, 'Question B'); + await act(async () => + harness.feeds[1].push({ type: 'run.started' }, { type: 'message.delta', messageId: 'b1', delta: 'B reply' }) + ); + + expect(panel.result.current.entry.answer).toMatchObject({ content: 'B reply' }); + expect(harness.run.mock.calls.map(([id]) => id)).toEqual(['a', 'b']); + + await act(async () => { + harness.feeds[0].push({ type: 'run.failed', message: 'A stopped' }); + harness.feeds[1].push({ type: 'run.failed', message: 'B stopped' }); + await Promise.all([a.completion, b.completion]); + }); + + expect(panel.result.current.entry.answer).toMatchObject({ status: 'error', message: 'B stopped' }); + expect(panel.result.current.entry.failure).toBeUndefined(); + expect(panel.result.current.entry.answerRecovery).toBe('send'); + + await act(async () => panel.result.current.select('a')); + + expect(panel.result.current.entry.draft).toBe('Next question A'); + }); + + it('never automatically resends an unknown question and permits an explicit send with the confirmed id', async () => { + const harness = sourceHarness(); + const panel = await mountPanel(harness.source); + + const sending = await sendQuestion(panel, 'Did the server accept this?'); + await act(async () => { + harness.feeds[0].push(new AssistantConnectionError()); + await sending.completion; + }); + + expect(panel.result.current.entry).toMatchObject({ phase: 'unknown', draft: 'Did the server accept this?' }); + expect(panel.result.current.entry.uncertainty).toContain('cannot establish'); + expect(harness.run).toHaveBeenCalledTimes(1); + + act(() => panel.result.current.setDraft('An explicit next question')); + harness.api.getMessages.mockResolvedValue({ + messages: [{ id: 'canonical-question', role: 'user', content: 'Did the server accept this?' }], + cursor: null + }); + + await act(async () => panel.result.current.reload()); + + expect(panel.result.current.entry).toMatchObject({ + phase: 'unknown', + draft: 'An explicit next question', + loaded: true + }); + expect(panel.result.current.entry.uncertainty).toContain('cannot establish'); + expect(panel.result.current.entry.messages.map(message => message.id)).toEqual(['canonical-question']); + expect(panel.result.current.entry.failure).toBeUndefined(); + + await act(async () => panel.result.current.select('b')); + const loads = harness.api.getMessages.mock.calls.length; + + await act(async () => panel.result.current.select('created')); + + expect(harness.api.getMessages).toHaveBeenCalledTimes(loads); + expect(harness.run).toHaveBeenCalledTimes(1); + + const next = await sendQuestion(panel); + + expect(panel.result.current.entry.phase).toBe('pending'); + expect(panel.result.current.entry.uncertainty).toContain('cannot establish'); + expect(harness.api.createConversation).toHaveBeenCalledTimes(1); + expect(harness.run.mock.calls.map(([id, content]) => [id, content])).toEqual([ + ['created', 'Did the server accept this?'], + ['created', 'An explicit next question'] + ]); + + await act(async () => { + harness.feeds[1].push({ type: 'run.started' }, { type: 'run.failed', message: 'Next run stopped' }); + await next.completion; + }); + + expect(panel.result.current.entry.phase).toBe('idle'); + expect(panel.result.current.entry.uncertainty).toContain('cannot establish'); + }); + + it('retains an interrupted accepted answer and allows an explicit next question without reloading', async () => { + const harness = sourceHarness(); + const panel = await mountPanel(harness.source); + + const sending = await sendQuestion(panel, 'Accepted question'); + await act(async () => { + harness.feeds[0].push( + { type: 'run.started' }, + { type: 'message.delta', messageId: 'p1', delta: 'Partial' }, + null + ); + await sending.completion; + }); + + expect(panel.result.current.entry).toMatchObject({ + phase: 'unknown', + draft: 'Accepted question', + submittedQuestion: 'Accepted question', + answer: { status: 'error', kind: 'stream', content: 'Partial' } + }); + expect(harness.run).toHaveBeenCalledTimes(1); + + const next = await sendQuestion(panel, 'Follow-up question'); + + expect(harness.run.mock.calls.map(([id]) => id)).toEqual(['created', 'created']); + expect(harness.api.getMessages).not.toHaveBeenCalled(); + expect(panel.result.current.entry.messages).toEqual([ + expect.objectContaining({ role: 'user', content: 'Accepted question' }), + expect.objectContaining({ + id: 'p1', + role: 'assistant', + segments: [expect.objectContaining({ id: 'p1', content: 'Partial' })] + }) + ]); + + await act(async () => { + harness.feeds[1].push({ type: 'run.failed', message: 'Follow-up rejected' }); + await next.completion; + }); + }); + + it('restores an accepted failed question and retries the same id without a mandatory history reload', async () => { + const harness = sourceHarness(); + const panel = await mountPanel(harness.source); + + const sending = await sendQuestion(panel, 'Accepted but failed'); + await act(async () => { + harness.feeds[0].push( + { type: 'run.started' }, + { type: 'message.delta', messageId: 'p1', delta: 'Preserved partial' }, + { type: 'tool_call.started', toolCallId: 't1', name: 'read_note' }, + { type: 'tool_call.done', toolCallId: 't1' }, + { type: 'run.failed', message: 'Execution failed' } + ); + await sending.completion; + }); + + expect(panel.result.current.entry).toMatchObject({ + phase: 'idle', + draft: 'Accepted but failed', + submittedQuestion: 'Accepted but failed', + answerRecovery: 'send', + answer: { + status: 'error', + kind: 'failed', + content: 'Preserved partial', + tools: [{ id: 't1', name: 'read_note', status: 'done' }] + } + }); + expect(panel.result.current.entry.failure).toBeUndefined(); + expect(panel.result.current.entry.messages).toHaveLength(1); + expect(harness.run).toHaveBeenCalledTimes(1); + expect(harness.api.getMessages).not.toHaveBeenCalled(); + + const retry = await sendQuestion(panel); + + expect(harness.api.createConversation).toHaveBeenCalledTimes(1); + expect(harness.run.mock.calls.map(([id, content]) => [id, content])).toEqual([ + ['created', 'Accepted but failed'], + ['created', 'Accepted but failed'] + ]); + expect(panel.result.current.entry.messages).toHaveLength(2); + + await act(async () => { + harness.feeds[1].push({ type: 'run.started' }); + }); + act(() => panel.result.current.setDraft('Keep my next draft')); + + await act(async () => { + harness.feeds[1].push({ type: 'run.failed', message: 'Retry failed' }); + await retry.completion; + }); + + expect(panel.result.current.entry.draft).toBe('Keep my next draft'); + }); + + it('replaces each message.done segment, keeps later rounds, and reloads the canonical completed answer', async () => { + const harness = sourceHarness(); + const canonical = deferred(); + harness.api.getMessages + .mockResolvedValueOnce({ messages: [], cursor: null }) + .mockImplementationOnce(() => canonical.promise); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + + const sending = await sendQuestion(panel, 'Multiple rounds'); + await act(async () => + harness.feeds[0].push( + { type: 'run.started' }, + { type: 'message.delta', messageId: 'm1', delta: 'Draft text' }, + { type: 'message.done', messageId: 'm1', content: 'First canonical round' }, + { type: 'tool_call.started', toolCallId: 'tool', name: 'read_note' }, + { type: 'tool_call.done', toolCallId: 'tool' }, + { type: 'message.delta', messageId: 'm2', delta: 'Second' }, + { type: 'message.done', messageId: 'm2', content: 'Second canonical round' } + ) + ); + + expect(panel.result.current.entry).toMatchObject({ + phase: 'running', + answer: { status: 'streaming', content: 'First canonical round\n\nSecond canonical round' } + }); + + await act(async () => harness.feeds[0].push({ type: 'run.completed' })); + + expect(panel.result.current.entry).toMatchObject({ + phase: 'idle', + loading: true, + answer: { + status: 'done', + message: { + id: 'm2', + segments: [ + { id: 'm1', content: 'First canonical round', toolCalls: [{ id: 'tool', name: 'read_note' }] }, + { id: 'm2', content: 'Second canonical round' } + ] + } + } + }); + + act(() => panel.result.current.setDraft('Next question')); + + await act(async () => { + canonical.resolve({ + messages: [ + { id: 'server-user', role: 'user', content: 'Multiple rounds' }, + { + id: 'm2', + role: 'assistant', + segments: [ + { id: 'm1', content: 'First canonical round' }, + { id: 'm2', content: 'Second canonical round' } + ] + } + ], + cursor: null + }); + await sending.completion; + }); + + expect(panel.result.current.entry).toMatchObject({ phase: 'idle', loading: false, draft: 'Next question' }); + expect(panel.result.current.entry.messages.map(message => message.id)).toEqual(['server-user', 'm2']); + expect(panel.result.current.entry.answer).toBeUndefined(); + }); + + it('starts a new send during completion history loading without losing either turn to the stale response', async () => { + const harness = sourceHarness(); + const canonical = deferred(); + harness.api.getMessages.mockImplementationOnce(() => canonical.promise); + const panel = await mountPanel(harness.source); + + const first = await sendQuestion(panel, 'First question'); + await act(async () => + harness.feeds[0].push( + { type: 'run.started' }, + { type: 'message.done', messageId: 'first-answer', content: 'First answer' }, + { type: 'run.completed' } + ) + ); + + expect(panel.result.current.entry).toMatchObject({ phase: 'idle', loading: true }); + + act(() => panel.result.current.setDraft('Second question')); + const second = await sendQuestion(panel); + + expect(panel.result.current.entry).toMatchObject({ phase: 'pending', loading: false, loadingEarlier: false }); + + await act(async () => + harness.feeds[1].push( + { type: 'run.started' }, + { type: 'message.delta', messageId: 'second-answer', delta: 'Second answer' } + ) + ); + act(() => panel.result.current.setDraft('A later draft')); + await act(async () => { + canonical.resolve({ + messages: [{ id: 'stale-user', role: 'user', content: 'First question' }], + cursor: 'stale-cursor' + }); + await first.completion; + }); + + expect(panel.result.current.entry).toMatchObject({ + phase: 'running', + loading: false, + loadingEarlier: false, + draft: 'A later draft', + cursor: null, + answer: { status: 'streaming', content: 'Second answer' } + }); + expect(panel.result.current.entry.messages).toEqual([ + expect.objectContaining({ role: 'user', content: 'First question' }), + expect.objectContaining({ + id: 'first-answer', + role: 'assistant', + segments: [expect.objectContaining({ id: 'first-answer', content: 'First answer' })] + }), + expect.objectContaining({ role: 'user', content: 'Second question' }) + ]); + + await act(async () => panel.result.current.send('Duplicate while running')); + + expect(harness.run).toHaveBeenCalledTimes(2); + + await act(async () => { + harness.feeds[1].push( + { type: 'message.delta', messageId: 'second-answer', delta: ' continues' }, + { type: 'run.failed', message: 'Second run stopped' } + ); + await second.completion; + }); + + expect(panel.result.current.entry.answer).toMatchObject({ content: 'Second answer continues' }); + expect(panel.result.current.entry.draft).toBe('A later draft'); + }); + + it('preserves a completed local answer after a history failure and permits the next send', async () => { + const harness = sourceHarness(); + harness.api.getMessages.mockRejectedValueOnce(new Error('History unavailable')); + const panel = await mountPanel(harness.source); + + const sending = await sendQuestion(panel, 'Complete locally'); + await act(async () => { + harness.feeds[0].push( + { type: 'run.started' }, + { type: 'message.done', messageId: 'm1', content: 'Complete answer' }, + { type: 'run.completed' } + ); + await sending.completion; + }); + + expect(panel.result.current.entry).toMatchObject({ + phase: 'idle', + loading: false, + failure: { message: 'History unavailable', kind: 'connection', recovery: 'reload' }, + answer: { status: 'done', message: { id: 'm1' } } + }); + + act(() => panel.result.current.setDraft('Next question')); + const next = await sendQuestion(panel); + + expect(harness.run.mock.calls.map(([id]) => id)).toEqual(['created', 'created']); + expect(panel.result.current.entry.failure).toBeUndefined(); + expect(panel.result.current.entry.messages).toEqual([ + expect.objectContaining({ role: 'user', content: 'Complete locally' }), + expect.objectContaining({ + id: 'm1', + role: 'assistant', + segments: [expect.objectContaining({ id: 'm1', content: 'Complete answer' })] + }) + ]); + + await act(async () => { + harness.feeds[1].push({ type: 'run.failed', message: 'Next run rejected' }); + await next.completion; + }); + }); + + it('passes the earlier cursor and joins a split answer chronologically without duplicating a bubble', async () => { + const harness = sourceHarness(); + harness.api.getMessages + .mockResolvedValueOnce({ + messages: [{ id: 'second', role: 'assistant', segments: [{ id: 'second', content: 'Later round' }] }], + cursor: 'earlier-page' + }) + .mockResolvedValueOnce({ + messages: [ + { id: 'question', role: 'user', content: 'History question' }, + { id: 'first', role: 'assistant', segments: [{ id: 'first', content: 'Earlier round' }] } + ], + cursor: null + }); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + + await act(async () => panel.result.current.loadEarlier()); + + expect(harness.api.getMessages.mock.calls).toEqual([['a'], ['a', 'earlier-page']]); + expect(panel.result.current.entry.messages).toEqual([ + { id: 'question', role: 'user', content: 'History question' }, + { + id: 'second', + role: 'assistant', + segments: [ + { id: 'first', content: 'Earlier round' }, + { id: 'second', content: 'Later round' } + ] + } + ]); + expect(panel.result.current.entry.cursor).toBeNull(); + + await act(async () => panel.result.current.loadEarlier()); + + expect(harness.api.getMessages).toHaveBeenCalledTimes(2); + }); + + it('stops a repeated history cursor without duplicating the already loaded pages', async () => { + const harness = sourceHarness(); + harness.api.getMessages + .mockResolvedValueOnce({ + messages: [{ id: 'latest', role: 'user', content: 'Latest question' }], + cursor: 'before-a' + }) + .mockResolvedValueOnce({ + messages: [{ id: 'older', role: 'user', content: 'Older question' }], + cursor: 'before-b' + }) + .mockResolvedValueOnce({ + messages: [{ id: 'oldest', role: 'user', content: 'Oldest question' }], + cursor: 'before-a' + }); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + + await act(async () => panel.result.current.loadEarlier()); + await act(async () => panel.result.current.loadEarlier()); + await act(async () => panel.result.current.loadEarlier()); + + expect(harness.api.getMessages).toHaveBeenCalledTimes(3); + expect(panel.result.current.entry.messages.map(message => message.id)).toEqual(['oldest', 'older', 'latest']); + }); + + it('ignores an old latest-page response after a newer reload has completed', async () => { + const harness = sourceHarness(); + const old = deferred(); + const latest = deferred(); + harness.api.getMessages.mockImplementationOnce(() => old.promise).mockImplementationOnce(() => latest.promise); + const panel = await mountPanel(harness.source); + + act(() => panel.result.current.select('a')); + let reload!: Promise; + act(() => { + reload = panel.result.current.reload(); + }); + await act(async () => { + latest.resolve({ messages: [{ id: 'new', role: 'user', content: 'New history' }], cursor: 'new-cursor' }); + await reload; + }); + + await act(async () => + old.resolve({ messages: [{ id: 'old', role: 'user', content: 'Stale history' }], cursor: null }) + ); + + expect(panel.result.current.entry.messages.map(message => message.id)).toEqual(['new']); + expect(panel.result.current.entry).toMatchObject({ loading: false, cursor: 'new-cursor' }); + }); + + it('invalidates an earlier-page response when latest history replaces it', async () => { + const harness = sourceHarness(); + const earlier = deferred(); + harness.api.getMessages + .mockResolvedValueOnce({ + messages: [{ id: 'old-latest', role: 'user', content: 'Before reload' }], + cursor: 'before' + }) + .mockImplementationOnce(() => earlier.promise) + .mockResolvedValueOnce({ messages: [{ id: 'new-latest', role: 'user', content: 'After reload' }], cursor: null }); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + + let pagination!: Promise; + act(() => { + pagination = panel.result.current.loadEarlier(); + }); + await act(async () => panel.result.current.reload()); + + await act(async () => { + earlier.resolve({ messages: [{ id: 'oldest', role: 'user', content: 'Stale earlier page' }], cursor: null }); + await pagination; + }); + + expect(panel.result.current.entry.messages.map(message => message.id)).toEqual(['new-latest']); + expect(panel.result.current.entry.loadingEarlier).toBe(false); + }); + + it('blocks direct sends before initial history succeeds without changing the draft or invalidating the read', async () => { + const harness = sourceHarness(); + const history = deferred(); + harness.api.getMessages.mockImplementationOnce(() => history.promise); + const panel = await mountPanel(harness.source); + act(() => panel.result.current.select('a')); + act(() => panel.result.current.setDraft('Keep this draft')); + const entry = panel.result.current.entry; + + await act(async () => panel.result.current.send('Do not replace the draft')); + await act(async () => panel.result.current.send()); + + expect(panel.result.current.entry).toBe(entry); + expect(panel.result.current.entry).toMatchObject({ + loaded: false, + loading: true, + phase: 'idle', + draft: 'Keep this draft', + messages: [] + }); + expect(harness.run).not.toHaveBeenCalled(); + expect(harness.api.createConversation).not.toHaveBeenCalled(); + expect(harness.api.getMessages.mock.calls).toEqual([['a']]); + + await act(async () => + history.resolve({ messages: [{ id: 'saved-question', role: 'user', content: 'Existing history' }], cursor: null }) + ); + + expect(panel.result.current.entry).toMatchObject({ + loaded: true, + loading: false, + phase: 'idle', + draft: 'Keep this draft', + messages: [{ id: 'saved-question', role: 'user', content: 'Existing history' }] + }); + expect(harness.api.getMessages).toHaveBeenCalledTimes(1); + }); + + it('allows sending after the latest page succeeds without loading its earlier cursor', async () => { + const harness = sourceHarness(); + const saved: AssistantMessagePage = { + messages: [ + { id: 'saved-answer', role: 'assistant', segments: [{ id: 'saved-round', content: 'Latest answer' }] } + ], + cursor: 'older-question' + }; + harness.api.getMessages.mockResolvedValueOnce(saved); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + + const sending = await sendQuestion(panel, 'Next question'); + await act(async () => harness.feeds[0].push({ type: 'run.started' })); + + expect(harness.api.getMessages.mock.calls).toEqual([['a']]); + expect(harness.api.createConversation).not.toHaveBeenCalled(); + expect(harness.run).toHaveBeenCalledWith('a', 'Next question', expect.any(AbortSignal)); + expect(panel.result.current.entry).toMatchObject({ loaded: true, cursor: 'older-question', phase: 'running' }); + expect(panel.result.current.entry.messages).toEqual([ + ...saved.messages, + expect.objectContaining({ role: 'user', content: 'Next question' }) + ]); + + await act(async () => { + harness.feeds[0].push({ type: 'run.failed', message: 'Stopped' }); + await sending.completion; + }); + }); + + it('allows sending during a later latest-page reload and ignores its stale result', async () => { + const harness = sourceHarness(); + const history = deferred(); + const saved: AssistantMessagePage = { + messages: [{ id: 'saved-question', role: 'user', content: 'Existing history' }], + cursor: 'earlier' + }; + harness.api.getMessages.mockResolvedValueOnce(saved).mockImplementationOnce(() => history.promise); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + let reload!: Promise; + act(() => { + reload = panel.result.current.reload(); + }); + + expect(panel.result.current.entry).toMatchObject({ loaded: true, loading: true }); + + const sending = await sendQuestion(panel, 'Question during reload'); + + expect(harness.run).toHaveBeenCalledWith('a', 'Question during reload', expect.any(AbortSignal)); + expect(panel.result.current.entry).toMatchObject({ phase: 'pending', loading: false }); + + await act(async () => { + history.resolve({ messages: [{ id: 'stale-question', role: 'user', content: 'Stale history' }], cursor: null }); + await reload; + }); + + expect(panel.result.current.entry).toMatchObject({ + loaded: true, + messages: saved.messages, + cursor: 'earlier', + draft: 'Question during reload', + phase: 'pending' + }); + + await act(async () => { + harness.feeds[0].push({ type: 'run.failed', message: 'Stopped' }); + await sending.completion; + }); + }); + + it('allows sending during earlier-page loading and ignores its late rejection without clearing the new answer', async () => { + const harness = sourceHarness(); + const earlier = deferred(); + harness.api.getMessages + .mockResolvedValueOnce({ + messages: [{ id: 'loaded-question', role: 'user', content: 'Loaded history' }], + cursor: 'earlier' + }) + .mockImplementationOnce(() => earlier.promise); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + + let pagination!: Promise; + act(() => { + pagination = panel.result.current.loadEarlier(); + }); + + expect(panel.result.current.entry.loadingEarlier).toBe(true); + + act(() => panel.result.current.setDraft('Question during pagination')); + const sending = await sendQuestion(panel); + + expect(panel.result.current.entry).toMatchObject({ phase: 'pending', loading: false, loadingEarlier: false }); + + await act(async () => + harness.feeds[0].push( + { type: 'run.started' }, + { type: 'message.delta', messageId: 'current-answer', delta: 'New answer' } + ) + ); + await act(async () => { + earlier.reject(new Error('Stale earlier-page error')); + await pagination; + }); + + expect(panel.result.current.entry).toMatchObject({ + phase: 'running', + cursor: 'earlier', + loadingEarlier: false, + answer: { status: 'streaming', content: 'New answer' } + }); + expect(panel.result.current.entry.failure).toBeUndefined(); + expect(panel.result.current.entry.messages).toEqual([ + { id: 'loaded-question', role: 'user', content: 'Loaded history' }, + expect.objectContaining({ role: 'user', content: 'Question during pagination' }) + ]); + + await act(async () => { + harness.feeds[0].push({ type: 'run.failed', message: 'Stopped' }); + await sending.completion; + }); + }); + + it('preserves a failed deletion and invalidates pending history after a confirmed deletion', async () => { + const harness = sourceHarness(); + const history = deferred(); + harness.api.getMessages.mockImplementationOnce(() => history.promise); + harness.api.deleteConversation.mockRejectedValueOnce(new Error('Conversation busy')); + const panel = await mountPanel(harness.source); + act(() => panel.result.current.select('a')); + + let deletion!: AssistantDeletionResult; + await act(async () => { + deletion = await panel.result.current.deleteConversation('a'); + }); + + expect(deletion).toEqual({ status: 'failed', message: 'Conversation busy' }); + expect(panel.result.current.conversations.map(item => item.id)).toContain('a'); + expect(panel.result.current.entry.failure).toEqual({ message: 'Conversation busy', kind: 'failed' }); + expect(panel.result.current.listError).toBeUndefined(); + expect(panel.result.current.entry.failure?.recovery).toBeUndefined(); + + await act(async () => { + deletion = await panel.result.current.deleteConversation('a'); + }); + + expect(deletion).toEqual({ status: 'deleted' }); + expect(panel.result.current.conversations.map(item => item.id)).not.toContain('a'); + + await act(async () => + history.resolve({ messages: [{ id: 'late', role: 'user', content: 'Deleted history' }], cursor: null }) + ); + + expect(panel.result.current.entry.conversation).toBeUndefined(); + expect(panel.result.current.entry.messages).toEqual([]); + expect(panel.result.current.entry.draft).toBe(''); + }); + + it('blocks sending only while deletion is pending and permits it after the API rejects deletion', async () => { + const harness = sourceHarness(); + const deletion = deferred(); + harness.api.deleteConversation.mockImplementationOnce(() => deletion.promise); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + + let removing!: Promise; + act(() => { + removing = panel.result.current.deleteConversation('a'); + }); + + act(() => panel.result.current.setDraft('Keep this draft')); + await act(async () => panel.result.current.send()); + + expect(harness.run).not.toHaveBeenCalled(); + expect(panel.result.current.entry).toMatchObject({ deleting: true, phase: 'idle', draft: 'Keep this draft' }); + + await act(async () => { + deletion.reject(new Error('Deletion rejected')); + await removing; + }); + + const sending = await sendQuestion(panel); + + expect(harness.run).toHaveBeenCalledWith('a', 'Keep this draft', expect.any(AbortSignal)); + + await act(async () => { + harness.feeds[0].push({ type: 'run.failed', message: 'Stopped' }); + await sending.completion; + }); + }); + + it('blocks read-only sending but allows the API to delete an uncertain conversation', async () => { + const harness = sourceHarness(); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('read-only')); + + await act(async () => panel.result.current.send('Forbidden')); + + expect(harness.run).not.toHaveBeenCalled(); + + await act(async () => panel.result.current.select('a')); + const sending = await sendQuestion(panel, 'Unknown acceptance'); + await act(async () => { + harness.feeds[0].push(new AssistantConnectionError()); + await sending.completion; + }); + + expect(panel.result.current.entry.phase).toBe('unknown'); + + let deletion!: AssistantDeletionResult; + await act(async () => { + deletion = await panel.result.current.deleteConversation('a'); + }); + + expect(deletion).toEqual({ status: 'deleted' }); + expect(harness.api.deleteConversation).toHaveBeenCalledWith('a'); + expect(panel.result.current.conversations.map(item => item.id)).not.toContain('a'); + expect(panel.result.current.entry.conversation).toBeUndefined(); + }); + + it('keeps consuming an active answer when deletion is pending and the API rejects it with 409', async () => { + const harness = sourceHarness(); + const deletion = deferred(); + harness.api.deleteConversation.mockImplementationOnce(() => deletion.promise); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + + const sending = await sendQuestion(panel, 'Keep running'); + await act(async () => + harness.feeds[0].push( + { type: 'run.started' }, + { type: 'message.delta', messageId: 'answer', delta: 'Before deletion' } + ) + ); + const signal = harness.run.mock.calls[0][2]; + + let removing!: Promise; + act(() => { + removing = panel.result.current.deleteConversation('a'); + }); + + expect(panel.result.current.entry.deleting).toBe(true); + expect(panel.result.current.conversations.map(item => item.id)).toContain('a'); + expect(signal.aborted).toBe(false); + + await act(async () => + harness.feeds[0].push({ type: 'message.delta', messageId: 'answer', delta: ' while deleting' }) + ); + + expect(panel.result.current.entry.answer).toMatchObject({ content: 'Before deletion while deleting' }); + + let result!: AssistantDeletionResult; + await act(async () => { + deletion.reject(new Error('409: Conversation busy')); + result = await removing; + }); + + expect(result).toEqual({ status: 'failed', message: '409: Conversation busy' }); + expect(signal.aborted).toBe(false); + expect(panel.result.current.entry).toMatchObject({ + phase: 'running', + deleting: false, + failure: { message: '409: Conversation busy', kind: 'failed' } + }); + expect(panel.result.current.entry.failure?.recovery).toBeUndefined(); + + await act(async () => + harness.feeds[0].push({ type: 'message.delta', messageId: 'answer', delta: ' after rejection' }) + ); + + expect(panel.result.current.entry.answer).toMatchObject({ + status: 'streaming', + content: 'Before deletion while deleting after rejection' + }); + + await act(async () => { + harness.feeds[0].push({ type: 'run.failed', message: 'Run stopped' }); + await sending.completion; + }); + }); + + it('aborts an active consumer only after deletion succeeds and ignores its late callbacks', async () => { + const harness = sourceHarness(); + const deletion = deferred(); + harness.api.deleteConversation.mockImplementationOnce(() => deletion.promise); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + + const sending = await sendQuestion(panel, 'Delete active conversation'); + await act(async () => harness.feeds[0].push({ type: 'run.started' })); + const signal = harness.run.mock.calls[0][2]; + + let removing!: Promise; + act(() => { + removing = panel.result.current.deleteConversation('a'); + }); + + expect(signal.aborted).toBe(false); + expect(panel.result.current.entry.conversation?.id).toBe('a'); + + let result!: AssistantDeletionResult; + await act(async () => { + deletion.resolve(undefined); + result = await removing; + }); + + expect(result).toEqual({ status: 'deleted' }); + expect(signal.aborted).toBe(true); + expect(panel.result.current.conversations.map(item => item.id)).not.toContain('a'); + + await act(async () => panel.result.current.select('b')); + const next = await sendQuestion(panel, 'B question'); + const loads = harness.api.getMessages.mock.calls.length; + + await act(async () => { + harness.feeds[0].push( + { type: 'message.delta', messageId: 'deleted-answer', delta: 'Stale answer' }, + { type: 'run.completed' } + ); + await sending.completion; + }); + + expect(harness.api.getMessages).toHaveBeenCalledTimes(loads); + expect(panel.result.current.entry).toMatchObject({ + conversation: { id: 'b' }, + phase: 'pending', + draft: 'B question', + messages: [] + }); + expect(panel.result.current.entry.answer).toBeUndefined(); + + await act(async () => { + harness.feeds[1].push({ type: 'run.failed', message: 'B rejected' }); + await next.completion; + }); + }); + + it('aborts local consumption and ignores callbacks after disposal without affecting a new panel', async () => { + const harness = sourceHarness(); + const panel = await mountPanel(harness.source); + const sending = await sendQuestion(panel, 'Old panel question'); + await act(async () => harness.feeds[0].push({ type: 'run.started' })); + const signal = harness.run.mock.calls[0][2]; + + panel.unmount(); + + expect(signal.aborted).toBe(true); + + const next = await mountPanel(harness.source); + await act(async () => { + harness.feeds[0].push({ type: 'run.completed' }); + await sending.completion; + }); + + expect(next.result.current.entry).toMatchObject({ draft: '', messages: [], phase: 'idle' }); + expect(next.result.current.entry.conversation).toBeUndefined(); + expect(harness.api.getMessages).not.toHaveBeenCalled(); + expect(harness.run).toHaveBeenCalledTimes(1); + }); + + it('ignores a confirmed create from an old source rather than sending it through either source', async () => { + const oldHarness = sourceHarness(); + const newHarness = sourceHarness(); + const creation = deferred(); + oldHarness.api.createConversation.mockImplementationOnce(() => creation.promise); + const panel = renderHook(({ source }) => useAssistantPanel(source), { + initialProps: { source: oldHarness.source } + }); + await act(async () => {}); + + const sending = await sendQuestion(panel, 'Old source draft'); + + expect(panel.result.current.entry.phase).toBe('creating'); + + await act(async () => panel.rerender({ source: newHarness.source })); + await act(async () => { + creation.resolve(conversation('obsolete-created')); + await sending.completion; + }); + + expect(oldHarness.run).not.toHaveBeenCalled(); + expect(newHarness.run).not.toHaveBeenCalled(); + expect(panel.result.current.entry).toMatchObject({ draft: '', messages: [], phase: 'idle' }); + expect(panel.result.current.entry.conversation).toBeUndefined(); + expect(panel.result.current.conversations.map(item => item.id)).not.toContain('obsolete-created'); + }); + + it('ignores StrictMode cleanup results and stale source responses', async () => { + const oldHarness = sourceHarness(); + const newHarness = sourceHarness(); + const obsolete = deferred(); + oldHarness.api.listConversations.mockImplementation(() => obsolete.promise); + newHarness.api.listConversations.mockResolvedValue([conversation('new-source')]); + const panel = renderHook(({ source }) => useAssistantPanel(source), { + initialProps: { source: oldHarness.source }, + wrapper: StrictMode + }); + + await act(async () => panel.rerender({ source: newHarness.source })); + await act(async () => obsolete.resolve([conversation('obsolete')])); + + expect(panel.result.current.conversations.map(item => item.id)).toEqual(['new-source']); + expect(panel.result.current.listLoading).toBe(false); + expect(panel.result.current.entry.draft).toBe(''); + expect(panel.result.current.entry.phase).toBe('idle'); + }); + + it('releases the real completed fixture consumer before a new send during history synchronization', async () => { + const source = createAssistantFixture({ + conversations: ASSISTANT_HISTORY_CONVERSATIONS, + now: FIXTURE_NOW, + delayMs: 0 + }); + const refresh = deferred(); + const getMessages = source.api.getMessages; + vi.spyOn(source.api, 'getMessages').mockImplementationOnce(() => refresh.promise); + const panel = await mountPanel(source); + + const first = await sendQuestion(panel, 'First question'); + + expect(panel.result.current.entry).toMatchObject({ phase: 'idle', loading: true }); + + const second = await sendQuestion(panel, 'Second question'); + await act(async () => second.completion); + const secondState = panel.result.current.entry; + const saved = await getMessages(secondState.conversation!.id); + + await act(async () => { + refresh.resolve({ messages: [], cursor: null }); + await first.completion; + }); + + expect(secondState.phase).toBe('idle'); + expect(secondState.uncertainty).toBeUndefined(); + expect(saved.messages.filter(message => message.role === 'user').map(message => message.content)).toEqual([ + 'First question', + 'Second question' + ]); + }); + + it('constructs a pure model with a cached initial snapshot and starts fresh sessions after disposal', async () => { + const harness = sourceHarness(); + const model = new AssistantPanelModel(harness.source); + const initial = model.getSnapshot(); + + expect(model.getSnapshot()).toBe(initial); + expect(initial.entry).toMatchObject({ key: 'draft:0', draft: '', phase: 'idle' }); + expect(harness.api.listConversations).not.toHaveBeenCalled(); + expect(harness.api.getMessages).not.toHaveBeenCalled(); + + model.start(); + model.setDraft('Discard on restart'); + model.dispose(); + model.start(); + + expect(model.getSnapshot().entry).toMatchObject({ key: 'draft:0', draft: '', phase: 'idle' }); + + await Promise.resolve(); + model.dispose(); + }); + + it('keeps a loaded failed answer after a later reload fails and permits the next send', async () => { + const harness = sourceHarness(); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + const sending = await sendQuestion(panel, 'Accepted but failed'); + await act(async () => { + harness.feeds[0].push( + { type: 'run.started' }, + { type: 'message.delta', messageId: 'partial', delta: 'Retained partial' }, + { type: 'run.failed', message: 'Execution failed' } + ); + await sending.completion; + }); + + harness.api.getMessages.mockRejectedValueOnce(new Error('History unavailable')); + + await act(async () => panel.result.current.reload()); + + expect(panel.result.current.entry).toMatchObject({ + loaded: true, + phase: 'idle', + failure: { message: 'History unavailable', kind: 'connection', recovery: 'reload' }, + answerRecovery: 'send', + answer: { status: 'error', kind: 'failed', message: 'Execution failed', content: 'Retained partial' } + }); + + expect(await panel.result.current.loadEarlier()).toBe(false); + + const next = await sendQuestion(panel, 'Next question'); + + expect(harness.api.createConversation).not.toHaveBeenCalled(); + expect(harness.run.mock.calls.map(([id, content]) => [id, content])).toEqual([ + ['a', 'Accepted but failed'], + ['a', 'Next question'] + ]); + expect(panel.result.current.entry.failure).toBeUndefined(); + expect(panel.result.current.entry.messages).toMatchObject([ + { role: 'user', content: 'Accepted but failed' }, + { id: 'partial', role: 'assistant', segments: [{ id: 'partial', content: 'Retained partial' }] } + ]); + + await act(async () => { + harness.feeds[1].push({ type: 'run.failed', message: 'Next question rejected' }); + await next.completion; + }); + }); + + it('renders a fresh initial snapshot synchronously when the source identity changes', async () => { + const oldHarness = sourceHarness(); + const newHarness = sourceHarness(); + const renders: { source: AssistantDataSource; panel: AssistantPanel }[] = []; + const panel = renderHook( + ({ source }) => { + const value = useAssistantPanel(source); + renders.push({ source, panel: value }); + return value; + }, + { initialProps: { source: oldHarness.source } } + ); + await act(async () => {}); + act(() => panel.result.current.setDraft('Old source only')); + + await act(async () => panel.rerender({ source: newHarness.source })); + + const firstNewRender = renders.find(render => render.source === newHarness.source)!; + expect(firstNewRender.panel.entry).toMatchObject({ key: 'draft:0', draft: '', messages: [], phase: 'idle' }); + expect(firstNewRender.panel.conversations).toEqual([]); + expect(firstNewRender.panel.listLoading).toBe(true); + }); + + it.each([ + { page: 'latest', outcome: 'success' }, + { page: 'latest', outcome: 'failure' }, + { page: 'earlier', outcome: 'success' }, + { page: 'earlier', outcome: 'failure' } + ] as const)( + 'ignores a $page $outcome result invalidated by a subscriber starting a new send', + async ({ page, outcome }) => { + const harness = sourceHarness(); + harness.api.getMessages.mockResolvedValueOnce({ messages: [], cursor: 'older' }); + const model = new AssistantPanelModel(harness.source); + model.start(); + await Promise.resolve(); + model.select('a'); + await Promise.resolve(); + await Promise.resolve(); + + const history = deferred(); + harness.api.getMessages.mockImplementationOnce(() => history.promise); + let sawLoading = false; + let nextSend: Promise | undefined; + const unsubscribe = model.subscribe(() => { + const entry = model.getSnapshot().entry; + if (entry.loading || entry.loadingEarlier) { + sawLoading = true; + return; + } + if (!sawLoading) return; + // Clear before send(): its synchronous notification must not submit a second time. + sawLoading = false; + nextSend = model.send('New submitted question'); + }); + + const previous = page === 'latest' ? model.reload() : model.loadEarlier(); + if (outcome === 'failure') history.reject(new Error('Obsolete history failure')); + else history.resolve({ messages: [], cursor: null }); + await previous; + + expect(nextSend).toBeDefined(); + expect(model.getSnapshot().entry).toMatchObject({ + phase: 'pending', + submittedQuestion: 'New submitted question', + draft: 'New submitted question' + }); + expect(model.getSnapshot().entry.failure).toBeUndefined(); + + unsubscribe(); + harness.feeds[0].push({ type: 'run.failed', message: 'Stop new run' }); + await nextSend; + model.dispose(); + } + ); + + it('keeps a failed initial read and draft unchanged by send until a successful history retry', async () => { + const harness = sourceHarness(); + const history = deferred(); + harness.api.getMessages.mockImplementationOnce(() => history.promise); + const panel = await mountPanel(harness.source); + act(() => panel.result.current.select('a')); + act(() => panel.result.current.setDraft('Keep this question')); + + await act(async () => history.reject(new Error('History unavailable'))); + + const entry = panel.result.current.entry; + + await act(async () => panel.result.current.send('Do not replace the draft')); + await act(async () => panel.result.current.send()); + + expect(panel.result.current.entry).toBe(entry); + expect(panel.result.current.entry).toMatchObject({ + loaded: false, + loading: false, + phase: 'idle', + draft: 'Keep this question', + failure: { message: 'History unavailable', kind: 'connection', recovery: 'reload' } + }); + expect(harness.api.getMessages.mock.calls).toEqual([['a']]); + expect(harness.api.createConversation).not.toHaveBeenCalled(); + expect(harness.run).not.toHaveBeenCalled(); + + harness.api.getMessages.mockResolvedValueOnce({ + messages: [{ id: 'saved', role: 'user', content: 'Recovered history' }], + cursor: null + }); + await act(async () => panel.result.current.reload()); + + expect(panel.result.current.entry).toMatchObject({ + loaded: true, + draft: 'Keep this question', + messages: [{ id: 'saved', role: 'user', content: 'Recovered history' }] + }); + expect(panel.result.current.entry.failure).toBeUndefined(); + expect(harness.api.getMessages.mock.calls).toEqual([['a'], ['a']]); + expect(harness.run).not.toHaveBeenCalled(); + + const sending = await sendQuestion(panel); + + expect(harness.run).toHaveBeenCalledWith('a', 'Keep this question', expect.any(AbortSignal)); + expect(harness.api.createConversation).not.toHaveBeenCalled(); + + await act(async () => { + harness.feeds[0].push({ type: 'run.failed', message: 'Stopped' }); + await sending.completion; + }); + }); + + it('refreshes activity metadata and ordering after a send in an existing conversation', async () => { + const source = createAssistantFixture({ + conversations: ASSISTANT_HISTORY_CONVERSATIONS, + now: FIXTURE_NOW, + delayMs: 0 + }); + const panel = await mountPanel(source); + await act(async () => panel.result.current.select('c2')); + await act(async () => panel.result.current.send('Follow-up question')); + + const saved = await source.api.listConversations(); + expect(panel.result.current.conversations).toEqual(saved); + expect(panel.result.current.conversations[0].id).toBe('c2'); + expect(panel.result.current.conversations[0].updatedAt).toBe(new Date(FIXTURE_NOW + 1_000).toISOString()); + }); + + it('orders refreshed activity by instant across ISO timestamp formats', async () => { + const harness = sourceHarness(); + const panel = await mountPanel(harness.source); + const older = { ...conversation('a'), updatedAt: '2026-10-19T12:00:00Z' }; + const newer = { ...conversation('b'), updatedAt: '2026-10-19T12:00:00.001Z' }; + harness.api.listConversations.mockResolvedValueOnce([older, newer]); + await act(async () => panel.result.current.select('a')); + const sending = await sendQuestion(panel, 'Refresh activity'); + await act(async () => { + harness.feeds[0].push({ type: 'run.started' }, { type: 'run.completed' }); + await sending.completion; + }); + + expect(panel.result.current.conversations.map(item => item.id)).toEqual(['b', 'a', 'read-only']); + }); + + it('preserves a session deletion failure across activity refresh without changing list errors', async () => { + const harness = sourceHarness(); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('b')); + const refresh = deferred(); + harness.api.listConversations.mockImplementationOnce(() => refresh.promise); + await act(async () => panel.result.current.select('a')); + const sending = await sendQuestion(panel, 'Refresh activity'); + await act(async () => { + harness.feeds[0].push({ type: 'run.started' }, { type: 'run.completed' }); + await sending.completion; + }); + harness.api.deleteConversation.mockRejectedValueOnce(new Error('Conversation busy')); + let result!: AssistantDeletionResult; + + await act(async () => { + result = await panel.result.current.deleteConversation('b'); + }); + + expect(result).toEqual({ status: 'failed', message: 'Conversation busy' }); + expect(panel.result.current.listError).toBeUndefined(); + + await act(async () => refresh.resolve([conversation('a'), conversation('b')])); + + expect(panel.result.current.listError).toBeUndefined(); + expect(panel.result.current.entry.failure).toBeUndefined(); + + await act(async () => panel.result.current.select('b')); + + expect(panel.result.current.entry.failure).toEqual({ message: 'Conversation busy', kind: 'failed' }); + }); + + it('clears a failed list read after a successful activity refresh', async () => { + const harness = sourceHarness(); + harness.api.listConversations.mockRejectedValueOnce(new Error('List unavailable')); + const panel = await mountPanel(harness.source); + expect(panel.result.current.listError).toBe('List unavailable'); + const sending = await sendQuestion(panel, 'Start a conversation'); + await act(async () => { + harness.feeds[0].push({ type: 'run.started' }, { type: 'run.completed' }); + await sending.completion; + }); + + expect(panel.result.current.listError).toBeUndefined(); + expect(panel.result.current.conversations.map(item => item.id)).toContain('a'); + }); + + it('ignores an older activity refresh that resolves after a newer send', async () => { + const harness = sourceHarness(); + const older = deferred(); + const newer = deferred(); + const panel = await mountPanel(harness.source); + harness.api.listConversations + .mockImplementationOnce(() => older.promise) + .mockImplementationOnce(() => newer.promise); + await act(async () => panel.result.current.select('a')); + const first = await sendQuestion(panel, 'First'); + await act(async () => { + harness.feeds[0].push({ type: 'run.started' }, { type: 'run.completed' }); + await first.completion; + }); + const second = await sendQuestion(panel, 'Second'); + await act(async () => { + harness.feeds[1].push({ type: 'run.started' }, { type: 'run.completed' }); + await second.completion; + }); + const fresh = { ...conversation('a'), updatedAt: '2026-10-20T00:00:00Z' }; + await act(async () => newer.resolve([fresh])); + await act(async () => older.resolve([conversation('a')])); + + expect(panel.result.current.conversations.find(item => item.id === 'a')).toEqual(fresh); + }); + + it.each([true, false])( + 'applies refreshed permissions to an existing session that was writable=%s', + async initiallyWritable => { + const harness = sourceHarness(); + harness.api.listConversations.mockResolvedValue([conversation('a', initiallyWritable), conversation('b')]); + const saved: AssistantMessagePage = { + messages: [{ id: 'saved-question', role: 'user', content: 'Keep this history' }], + cursor: 'older' + }; + harness.api.getMessages.mockResolvedValue(saved); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + act(() => panel.result.current.setDraft('Keep the next A question')); + const key = panel.result.current.entry.key; + const refreshed = { + ...conversation('a', !initiallyWritable), + title: 'Updated A', + ownerId: 'updated-owner', + updatedAt: '2026-10-20T00:00:00Z' + }; + harness.api.listConversations.mockResolvedValue([refreshed, conversation('b')]); + await act(async () => panel.result.current.select('b')); + + const sending = await sendQuestion(panel, 'Refresh from B'); + await act(async () => { + harness.feeds[0].push({ type: 'run.started' }, { type: 'run.completed' }); + await sending.completion; + }); + await act(async () => panel.result.current.select('a')); + + expect(panel.result.current.entry).toMatchObject({ + key, + conversation: refreshed, + draft: 'Keep the next A question', + messages: saved.messages, + cursor: 'older' + }); + + const followUp = await sendQuestion(panel); + + expect(harness.run.mock.calls.map(([id, content]) => [id, content])).toEqual( + initiallyWritable + ? [['b', 'Refresh from B']] + : [ + ['b', 'Refresh from B'], + ['a', 'Keep the next A question'] + ] + ); + expect(panel.result.current.entry.draft).toBe('Keep the next A question'); + expect(panel.result.current.entry.phase).toBe(initiallyWritable ? 'idle' : 'pending'); + + await act(async () => { + if (!initiallyWritable) harness.feeds[1].push({ type: 'run.failed', message: 'Stop follow-up' }); + await followUp.completion; + }); + } + ); + + it('updates metadata without interrupting an active answer or clearing its next draft', async () => { + const harness = sourceHarness(); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + const first = await sendQuestion(panel, 'Question for A'); + await act(async () => + harness.feeds[0].push( + { type: 'run.started' }, + { type: 'message.delta', messageId: 'answer-a', delta: 'A is running' } + ) + ); + act(() => panel.result.current.setDraft('Next A question')); + const key = panel.result.current.entry.key; + const refreshed = { ...conversation('a', false), title: 'Updated running A' }; + harness.api.listConversations.mockResolvedValue([refreshed, conversation('b')]); + await act(async () => panel.result.current.select('b')); + + const second = await sendQuestion(panel, 'Question for B'); + await act(async () => { + harness.feeds[1].push({ type: 'run.started' }, { type: 'run.completed' }); + await second.completion; + }); + await act(async () => panel.result.current.select('a')); + + expect(panel.result.current.entry).toMatchObject({ + key, + conversation: refreshed, + phase: 'running', + draft: 'Next A question', + answer: { status: 'streaming', content: 'A is running' } + }); + expect(harness.run.mock.calls[0][2].aborted).toBe(false); + + await act(async () => { + harness.feeds[0].push( + { type: 'message.delta', messageId: 'answer-a', delta: ' and finishes' }, + { type: 'run.failed', message: 'Stop A' } + ); + await first.completion; + }); + + expect(panel.result.current.entry.answer).toMatchObject({ content: 'A is running and finishes' }); + expect(panel.result.current.entry.draft).toBe('Next A question'); + }); + + it.each([false, true])('reloads history without losing a failed answer, persisted partial=%s', async persisted => { + const harness = sourceHarness(); + const content = 'Repeated question'; + const questions: AssistantMessagePage['messages'] = [ + { id: 'older-question', role: 'user', content }, + { id: 'older-answer', role: 'assistant', segments: [{ id: 'older-round', content: 'Older answer' }] }, + { id: 'saved-question', role: 'user', content } + ]; + harness.api.getMessages + .mockResolvedValueOnce({ messages: questions.slice(0, -1), cursor: 'earlier' }) + .mockResolvedValueOnce({ + messages: persisted + ? [...questions, { id: 'saved-answer', role: 'assistant', segments: [{ id: 'round', content: 'Partial' }] }] + : questions, + cursor: 'earlier' + }); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + const sending = await sendQuestion(panel, content); + await act(async () => + harness.feeds[0].push( + { type: 'run.started' }, + { type: 'message.done', messageId: 'round', content: 'Partial' }, + { type: 'tool_call.started', toolCallId: 'tool', name: 'read_note' }, + { type: 'tool_call.done', toolCallId: 'tool' }, + { type: 'message.delta', messageId: 'continuation', delta: 'Unsaved continuation' } + ) + ); + const answerKey = panel.result.current.entry.answerKey; + const questionKey = panel.result.current.entry.messages.at(-1)!.id; + + await act(async () => { + harness.feeds[0].push({ type: 'run.failed', message: 'Execution failed' }); + await sending.completion; + }); + + await act(async () => panel.result.current.reload()); + + expect(panel.result.current.entry).toMatchObject({ + loaded: true, + loading: false, + cursor: 'earlier', + draft: content, + answerKey, + answerRecovery: 'send', + answer: { + status: 'error', + message: 'Execution failed', + content: 'Partial\n\nUnsaved continuation', + tools: [{ id: 'tool', name: 'read_note', status: 'done' }] + } + }); + expect(panel.result.current.entry.messages).toEqual(questions); + expect(panel.result.current.entry.messageKeys.get('saved-question')).toBe(questionKey); + expect(harness.run).toHaveBeenCalledTimes(1); + }); + + it('resumes deferred completion history after deletion fails', async () => { + const harness = sourceHarness(); + const canonical = Promise.withResolvers(); + const deletion = Promise.withResolvers(); + const saved: AssistantMessagePage = { + messages: [{ id: 'earlier-question', role: 'user', content: 'Earlier question' }], + cursor: 'earlier' + }; + harness.api.getMessages.mockResolvedValueOnce(saved).mockImplementationOnce(() => canonical.promise); + harness.api.deleteConversation.mockImplementationOnce(() => deletion.promise); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + const sending = await sendQuestion(panel, 'Complete this question'); + await act(async () => + harness.feeds[0].push( + { type: 'run.started' }, + { type: 'message.done', messageId: 'round', content: 'Completed answer' } + ) + ); + let removing!: Promise; + act(() => { + removing = panel.result.current.deleteConversation('a'); + }); + + await act(async () => { + harness.feeds[0].push({ type: 'run.completed' }); + await sending.completion; + }); + + expect(harness.api.getMessages).toHaveBeenCalledTimes(1); + expect(panel.result.current.entry).toMatchObject({ deleting: true, answer: { status: 'done' } }); + + let result!: AssistantDeletionResult; + await act(async () => { + deletion.reject(new Error('Deletion denied')); + result = await removing; + }); + + expect(result).toEqual({ status: 'failed', message: 'Deletion denied' }); + expect(panel.result.current.entry).toMatchObject({ + deleting: false, + loading: true, + failure: { message: 'Deletion denied', kind: 'failed' } + }); + const page: AssistantMessagePage = { + messages: [ + ...saved.messages, + { id: 'saved-question', role: 'user', content: 'Complete this question' }, + { id: 'saved-answer', role: 'assistant', segments: [{ id: 'round', content: 'Completed answer' }] } + ], + cursor: 'earlier' + }; + + await act(async () => canonical.resolve(page)); + + expect(panel.result.current.entry).toMatchObject({ + loaded: true, + loading: false, + messages: page.messages, + cursor: 'earlier', + failure: { message: 'Deletion denied', kind: 'failed' } + }); + expect(panel.result.current.entry.answer).toBeUndefined(); + expect(harness.api.getMessages).toHaveBeenCalledTimes(2); + expect(harness.run).toHaveBeenCalledTimes(1); + }); + + it('preserves a recovered failed turn while loading distinct older history and promotes its partial once', async () => { + const harness = sourceHarness(); + harness.api.getMessages + .mockResolvedValueOnce({ + messages: [{ id: 'loaded-question', role: 'user', content: 'Previously loaded question' }], + cursor: 'before-loaded-question' + }) + .mockResolvedValueOnce({ + messages: [ + { id: 'loaded-question', role: 'user', content: 'Previously loaded question' }, + { id: 'saved-question', role: 'user', content: 'Failed question' }, + { id: 'saved-answer', role: 'assistant', segments: [{ id: 'round', content: 'Partial' }] } + ], + cursor: 'before-loaded-question' + }) + .mockResolvedValueOnce({ + messages: [ + { id: 'oldest-question', role: 'user', content: 'Oldest question' }, + { id: 'oldest-answer', role: 'assistant', segments: [{ id: 'oldest-round', content: 'Oldest answer' }] } + ], + cursor: null + }); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + const sending = await sendQuestion(panel, 'Failed question'); + await act(async () => { + harness.feeds[0].push( + { type: 'run.started' }, + { type: 'message.delta', messageId: 'round', delta: 'Partial' }, + { type: 'run.failed', message: 'Execution failed' } + ); + await sending.completion; + }); + const answerKey = panel.result.current.entry.answerKey; + const questionKey = panel.result.current.entry.messages.at(-1)!.id; + + await act(async () => panel.result.current.reload()); + + expect(panel.result.current.entry).toMatchObject({ + cursor: 'before-loaded-question', + answerKey, + answerRecovery: 'send', + answer: { status: 'error', message: 'Execution failed', content: 'Partial' } + }); + expect(panel.result.current.entry.messages).toEqual([ + { id: 'loaded-question', role: 'user', content: 'Previously loaded question' }, + { id: 'saved-question', role: 'user', content: 'Failed question' } + ]); + expect(panel.result.current.entry.messageKeys.get('saved-question')).toBe(questionKey); + + await act(async () => panel.result.current.loadEarlier()); + + expect(harness.api.getMessages.mock.calls).toEqual([['a'], ['a'], ['a', 'before-loaded-question']]); + expect(panel.result.current.entry.messages).toEqual([ + { id: 'oldest-question', role: 'user', content: 'Oldest question' }, + { id: 'oldest-answer', role: 'assistant', segments: [{ id: 'oldest-round', content: 'Oldest answer' }] }, + { id: 'loaded-question', role: 'user', content: 'Previously loaded question' }, + { id: 'saved-question', role: 'user', content: 'Failed question' } + ]); + expect(panel.result.current.entry).toMatchObject({ + cursor: null, + answerKey, + answerRecovery: 'send', + answer: { status: 'error', message: 'Execution failed', content: 'Partial' } + }); + + const retry = await sendQuestion(panel, 'Another question'); + await act(async () => { + harness.feeds[1].push({ type: 'run.failed', message: 'Not accepted' }); + await retry.completion; + }); + + expect(panel.result.current.entry.messages.filter(message => message.role === 'assistant')).toMatchObject([ + { id: 'oldest-answer', role: 'assistant', segments: [{ id: 'oldest-round', content: 'Oldest answer' }] }, + { id: 'round', role: 'assistant', segments: [{ id: 'round', content: 'Partial' }] } + ]); + expect(panel.result.current.entry.localAnswerViews.get('round')).toMatchObject({ + status: 'error', + message: 'Execution failed' + }); + }); + + it('keeps failed recovery available for explicit reload without retrying automatically', async () => { + const harness = sourceHarness(); + harness.api.getMessages + .mockResolvedValueOnce({ messages: [], cursor: null }) + .mockRejectedValueOnce(new Error('History unavailable')); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + const sending = await sendQuestion(panel, 'Failed question'); + + await act(async () => { + harness.feeds[0].push({ type: 'run.started' }, { type: 'run.failed', message: 'Execution failed' }); + await sending.completion; + await panel.result.current.reload(); + }); + + expect(panel.result.current.entry).toMatchObject({ + loaded: true, + failure: { message: 'History unavailable', recovery: 'reload' }, + answer: { status: 'error', message: 'Execution failed' } + }); + expect(harness.api.getMessages).toHaveBeenCalledTimes(2); + harness.api.getMessages.mockResolvedValueOnce({ + messages: [{ id: 'saved-question', role: 'user', content: 'Failed question' }], + cursor: 'earlier' + }); + + await act(async () => panel.result.current.reload()); + + expect(panel.result.current.entry).toMatchObject({ + loaded: true, + cursor: 'earlier', + draft: 'Failed question', + answerRecovery: 'send', + answer: { status: 'error', message: 'Execution failed' } + }); + expect(panel.result.current.entry.messages).toEqual([ + { id: 'saved-question', role: 'user', content: 'Failed question' } + ]); + expect(panel.result.current.entry.failure).toBeUndefined(); + expect(harness.api.getMessages).toHaveBeenCalledTimes(3); + expect(harness.run).toHaveBeenCalledTimes(1); + }); + + it('does not resume deferred history when deletion succeeds after completion', async () => { + const harness = sourceHarness(); + const deletion = Promise.withResolvers(); + harness.api.deleteConversation.mockImplementationOnce(() => deletion.promise); + const panel = await mountPanel(harness.source); + await act(async () => panel.result.current.select('a')); + const sending = await sendQuestion(panel, 'Complete while deleting'); + await act(async () => harness.feeds[0].push({ type: 'run.started' })); + let removing!: Promise; + act(() => { + removing = panel.result.current.deleteConversation('a'); + }); + + await act(async () => { + harness.feeds[0].push({ type: 'run.completed' }); + await sending.completion; + deletion.resolve(); + await removing; + }); + + expect(panel.result.current.conversations.map(item => item.id)).not.toContain('a'); + expect(panel.result.current.entry.conversation).toBeUndefined(); + expect(harness.api.getMessages).toHaveBeenCalledTimes(1); + }); + + it('does not submit after disposal between conversation confirmation and the send continuation', async () => { + const harness = sourceHarness(); + const run = vi.fn(async function* (): AsyncIterable { + yield { type: 'run.started' }; + yield { type: 'run.completed' }; + }); + const model = new AssistantPanelModel({ ...harness.source, run }); + model.start(); + const unsubscribe = model.subscribe(() => { + if (model.getSnapshot().entry.conversation?.id === 'created') queueMicrotask(() => model.dispose()); + }); + + await model.send('Do not submit after disposal'); + unsubscribe(); + model.dispose(); + + expect(harness.api.createConversation).toHaveBeenCalledTimes(1); + expect(run).not.toHaveBeenCalled(); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/useAssistantPanel.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/useAssistantPanel.ts new file mode 100644 index 00000000000..404f0f3df84 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/model/useAssistantPanel.ts @@ -0,0 +1,28 @@ +/* + * 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 { useEffect, useMemo, useSyncExternalStore } from 'react'; +import type { AssistantDataSource } from './assistantDataSource'; +import { AssistantPanelModel } from './assistantPanelModel'; +import type { AssistantPanel } from './assistantPanelState'; + +export const useAssistantPanel = (source: AssistantDataSource): AssistantPanel => { + const model = useMemo(() => new AssistantPanelModel(source), [source]); + const panel = useSyncExternalStore(model.subscribe, model.getSnapshot); + + useEffect(() => { + model.start(); + return () => model.dispose(); + }, [model]); + + return panel; +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/AssistantConversationPanel.css.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/AssistantConversationPanel.css.ts new file mode 100644 index 00000000000..fab9fcb97b4 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/AssistantConversationPanel.css.ts @@ -0,0 +1,196 @@ +/* + * 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 panel = style({ + position: 'relative', + display: 'flex', + flex: 1, + minWidth: 0, + minHeight: 0, + height: '100%', + overflow: 'hidden', + color: vars.text, + background: vars.bg +}); + +export const content = style({ + display: 'flex', + flex: 1, + minWidth: 0, + minHeight: 0 +}); + +export const conversation = style({ + display: 'flex', + flexDirection: 'column', + flex: 1, + minWidth: 0, + minHeight: 0 +}); + +export const hidden = style({ display: 'none' }); + +export const messages = style({ + flex: '1 1 0', + minWidth: 0, + minHeight: 0, + overflowY: 'auto', + overflowX: 'hidden', + overflowAnchor: 'none', + overscrollBehavior: 'contain', + scrollbarGutter: 'stable', + padding: `${vars.space4} ${vars.space3} ${vars.space2}` +}); + +export const messageStack = style({ + display: 'flex', + flexDirection: 'column', + gap: vars.space4, + minWidth: 0 +}); + +export const messageRow = style({ + display: 'flex', + flexDirection: 'column', + minWidth: 0 +}); + +export const statusArea = style({ + flexShrink: 1, + minHeight: 0, + maxHeight: '40%', + overflowY: 'auto', + overscrollBehavior: 'contain' +}); + +export const note = style({ + flexShrink: 0, + margin: 0, + padding: `${vars.space2} ${vars.space3}`, + color: vars.textSecondary, + fontSize: vars.fontSizeSm, + overflowWrap: 'anywhere' +}); + +export const empty = style({ + margin: 0, + padding: `${vars.space5} ${vars.space3}`, + color: vars.textSecondary, + textAlign: 'center' +}); + +export const warning = style({ + flexShrink: 0, + margin: vars.space2, + padding: vars.space2, + color: vars.text, + background: vars.fill, + border: `1px solid ${vars.borderStrong}`, + borderRadius: vars.radiusSm, + fontSize: vars.fontSizeSm, + overflowWrap: 'anywhere' +}); + +export const composer = style({ + flexShrink: 0, + minWidth: 0, + margin: 0, + padding: 0, + border: 0 +}); + +export const listView = style({ + flex: 1, + minWidth: 0, + minHeight: 0, + overflowY: 'auto', + overflowX: 'hidden', + overscrollBehavior: 'contain', + padding: vars.space2 +}); + +export const listNavigation = style({ paddingBottom: vars.space2 }); + +export const action = style({ + minHeight: 30, + maxWidth: '100%', + padding: `${vars.space1} ${vars.space2}`, + border: `1px solid ${vars.borderStrong}`, + borderRadius: vars.radiusSm, + color: vars.text, + background: vars.bg, + font: 'inherit', + fontSize: vars.fontSizeSm, + lineHeight: vars.lineHeight, + cursor: 'pointer', + overflowWrap: 'anywhere', + selectors: { + '&:hover:not(:disabled)': { background: vars.fill }, + '&:disabled': { cursor: 'not-allowed', opacity: 0.6 } + } +}); + +export const primaryAction = style([ + action, + { + color: vars.accentText, + borderColor: vars.accentBorder, + background: vars.accentBg + } +]); + +export const confirmationBackdrop = style({ + position: 'absolute', + inset: 0, + zIndex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: vars.space3, + background: vars.bg, + overflowY: 'auto', + overscrollBehavior: 'contain' +}); + +export const confirmation = style({ + width: '100%', + maxWidth: 360, + maxHeight: '100%', + overflowY: 'auto', + padding: vars.space3, + border: `1px solid ${vars.borderStrong}`, + borderRadius: vars.radius, + background: vars.bg, + boxShadow: vars.shadow, + overflowWrap: 'anywhere' +}); + +export const confirmationTitle = style({ + margin: `0 0 ${vars.space2}`, + fontSize: vars.fontSize, + color: vars.text +}); + +export const confirmationDescription = style({ + margin: `0 0 ${vars.space3}`, + color: vars.textSecondary +}); + +export const confirmationActions = style({ + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'flex-end', + gap: vars.space2 +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/AssistantConversationPanel.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/AssistantConversationPanel.spec.tsx new file mode 100644 index 00000000000..31b83daf965 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/AssistantConversationPanel.spec.tsx @@ -0,0 +1,1020 @@ +/* + * 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 { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { AssistantConnectionError, type AssistantRunEvent } from '@/entities/assistant/model/assistantContract'; +import { createAssistantFixture, type AssistantFixtureScenario } from '../fixtures/assistantFixture'; +import { ASSISTANT_HISTORY_CONVERSATIONS, FIXTURE_NOW } from '../fixtures/assistantFixtureData'; +import type { AssistantDataSource } from '../model/assistantDataSource'; +import { AssistantConversationPanel } from './AssistantConversationPanel'; + +const flush = async () => { + await act(async () => {}); +}; + +const deferred = () => { + let resolve!: () => void; + const promise = new Promise(accept => { + resolve = accept; + }); + + return { promise, resolve }; +}; + +const input = () => screen.getByRole('textbox', { name: 'Question' }) as HTMLTextAreaElement; +const send = () => screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement; +const listTrigger = () => screen.getByRole('button', { name: 'Open conversations' }); +const messages = () => screen.getByRole('region', { name: 'Conversation messages' }); + +const mountPanel = async (source: AssistantDataSource, onKeyDown = vi.fn()) => { + render( +
+ +
+ ); + await flush(); + return { onKeyDown }; +}; + +const select = async (title: string) => { + fireEvent.click(listTrigger()); + fireEvent.click(screen.getByRole('button', { name: new RegExp(`^${title}`) })); + await flush(); +}; + +const submit = async (question: string) => { + fireEvent.change(input(), { target: { value: question } }); + fireEvent.click(send()); + await flush(); +}; + +const fixture = (scenario?: AssistantFixtureScenario) => + createAssistantFixture({ conversations: ASSISTANT_HISTORY_CONVERSATIONS, now: FIXTURE_NOW, scenario, delayMs: 0 }); + +const streamingFixture = () => { + const source = fixture(); + const firstDelta = deferred(); + const secondDelta = deferred(); + const finish = deferred(); + const run = vi.fn(async function* (): AsyncIterable { + yield { type: 'run.started' }; + await firstDelta.promise; + yield { type: 'message.delta', messageId: 'controlled-answer', delta: 'Only revenue A' }; + await secondDelta.promise; + yield { type: 'message.delta', messageId: 'controlled-answer', delta: ' continues' }; + await finish.promise; + yield { type: 'run.failed', message: 'Controlled run stopped.' }; + }); + + return { source: { ...source, run }, run, firstDelta, secondDelta, finish }; +}; + +describe('AssistantConversationPanel', () => { + it('opens saved conversations and restores the trigger after select, Back and Escape without closing the shell', async () => { + const source = fixture(); + const getMessages = vi.spyOn(source.api, 'getMessages'); + const { onKeyDown } = await mountPanel(source); + + await select('Revenue analysis'); + + expect(screen.getByText('Revenue increased in all three regions, led by Europe.')).toBeTruthy(); + expect(document.activeElement).toBe(listTrigger()); + + fireEvent.change(input(), { target: { value: 'Keep my selected draft' } }); + + fireEvent.click(listTrigger()); + + expect(screen.getByRole('button', { name: /^Revenue analysis/ }).getAttribute('aria-current')).toBe('true'); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + + expect(input().value).toBe('Keep my selected draft'); + expect(document.activeElement).toBe(listTrigger()); + + fireEvent.click(listTrigger()); + const search = screen.getByRole('textbox', { name: 'Search conversations' }); + search.focus(); + fireEvent.keyDown(search, { key: 'Escape' }); + + expect(screen.queryByRole('textbox', { name: 'Search conversations' })).toBeNull(); + expect(input().value).toBe('Keep my selected draft'); + expect(document.activeElement).toBe(listTrigger()); + expect(onKeyDown).not.toHaveBeenCalled(); + expect(getMessages.mock.calls).toEqual([['c1']]); + + fireEvent.keyDown(input(), { key: 'ArrowLeft' }); + + expect(onKeyDown).toHaveBeenCalledTimes(1); + }); + + it('renders a saved answer whose opaque id matches an object property', async () => { + const source = fixture(); + vi.spyOn(source.api, 'getMessages').mockResolvedValueOnce({ + messages: [ + { + id: 'constructor', + role: 'assistant', + segments: [{ id: 'saved-segment', content: 'This saved answer must remain visible.' }] + } + ], + cursor: null + }); + await mountPanel(source); + + await select('Revenue analysis'); + + expect(within(messages()).getByText('This saved answer must remain visible.')).toBeTruthy(); + }); + + it('loads earlier history after a rejected send supersedes its pending page request', async () => { + // Make the viewport eligible for automatic history loading. + const height = vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(100); + const width = vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(100); + const earlier = deferred(); + + try { + const source = fixture('send-rejection'); + const run = vi.spyOn(source, 'run'); + const getMessages = source.api.getMessages; + let pending = true; + vi.spyOn(source.api, 'getMessages').mockImplementation(async (id, before) => { + if (before && pending) { + pending = false; + await earlier.promise; + } + return getMessages(id, before); + }); + await mountPanel(source); + + await select('Revenue analysis'); + + expect(source.api.getMessages).toHaveBeenCalledWith('c1', 'c1:earlier'); + expect(screen.getByText('Loading earlier messages…')).toBeTruthy(); + expect(input().disabled).toBe(false); + expect(input().readOnly).toBe(false); + + fireEvent.change(input(), { target: { value: 'A question rejected before acceptance' } }); + + expect(send().matches(':disabled')).toBe(false); + + fireEvent.click(send()); + await flush(); + + expect(run).toHaveBeenCalledExactlyOnceWith( + 'c1', + 'A question rejected before acceptance', + expect.any(AbortSignal) + ); + + await act(async () => earlier.resolve()); + + expect(await within(messages()).findByText('I checked the regional revenue paragraph.')).toBeTruthy(); + expect(input().value).toBe('A question rejected before acceptance'); + } finally { + earlier.resolve(); + height.mockRestore(); + width.mockRestore(); + } + }); + + it('keeps initial and repeated New local, preserving its draft until the first send', async () => { + const source = fixture(); + const create = vi.spyOn(source.api, 'createConversation'); + const run = vi.spyOn(source, 'run'); + await mountPanel(source); + + fireEvent.click(screen.getByRole('button', { name: 'New conversation' })); + fireEvent.change(input(), { target: { value: 'Local draft question' } }); + await select('Notebook summary'); + fireEvent.click(screen.getByRole('button', { name: 'New conversation' })); + + expect(input().value).toBe('Local draft question'); + + fireEvent.click(screen.getByRole('button', { name: 'New conversation' })); + + expect(create).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + + fireEvent.click(send()); + await flush(); + + expect(create).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith('c4', 'Local draft question', expect.any(AbortSignal)); + expect(screen.getAllByRole('article', { name: 'Your question' })).toHaveLength(1); + expect( + within(screen.getByRole('article', { name: 'Your question' })).getByText('Local draft question') + ).toBeTruthy(); + expect(screen.getAllByRole('article', { name: 'Assistant answer' })).toHaveLength(1); + expect(screen.getByRole('table')).toBeTruthy(); + expect(screen.getByText('12%', { selector: 'strong' })).toBeTruthy(); + }); + + it('disables only New during creation, including the list, and enables it before the accepted run finishes', async () => { + const source = fixture(); + const creation = deferred(); + const acceptance = deferred(); + const create = source.api.createConversation; + source.api.createConversation = async body => { + await creation.promise; + return create(body); + }; + source.run = async function* () { + await acceptance.promise; + yield { type: 'run.started' }; + yield { type: 'run.failed', message: 'Controlled completion.' }; + }; + await mountPanel(source); + + await submit('Create this draft'); + + expect(screen.getByRole('button', { name: 'New conversation' }).matches(':disabled')).toBe(true); + + await select('Notebook summary'); + + expect(input().disabled).toBe(false); + + fireEvent.change(input(), { target: { value: 'Existing conversation remains editable' } }); + + expect(send().matches(':disabled')).toBe(false); + expect(screen.getByRole('button', { name: 'New conversation' }).matches(':disabled')).toBe(true); + + fireEvent.click(listTrigger()); + + expect(screen.getByRole('button', { name: 'New conversation' }).matches(':disabled')).toBe(true); + + await act(async () => creation.resolve()); + + expect(screen.getByRole('button', { name: 'New conversation' }).matches(':disabled')).toBe(false); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + + expect(input().value).toBe('Existing conversation remains editable'); + + fireEvent.click(screen.getByRole('button', { name: 'New conversation' })); + + expect(input().value).toBe(''); + + await act(async () => acceptance.resolve()); + }); + + it('preserves question selection and expanded tool focus through completion and saved history', async () => { + const source = fixture(); + const completion = deferred(); + const history = deferred(); + const originalRun = source.run; + const originalGetMessages = source.api.getMessages; + source.run = async function* (id, content, signal) { + for await (const event of originalRun(id, content, signal)) { + if (event.type === 'run.completed') await completion.promise; + yield event; + } + }; + vi.spyOn(source.api, 'getMessages').mockImplementationOnce(async id => { + await history.promise; + return originalGetMessages(id); + }); + await mountPanel(source); + + await submit('Keep this question selected'); + + const summary = screen.getByText('Used 1 tool'); + summary.closest('details')!.open = true; + summary.focus(); + const selection = window.getSelection()!; + const range = document.createRange(); + range.selectNodeContents(screen.getByText('Keep this question selected')); + selection.removeAllRanges(); + selection.addRange(range); + + await act(async () => completion.resolve()); + + expect(within(messages()).queryByText('Loading conversation…')).toBeNull(); + expect(screen.getByText('Used 1 tool').closest('details')!.open).toBe(true); + expect(document.activeElement).toBe(summary); + expect(selection.toString()).toBe('Keep this question selected'); + + await act(async () => history.resolve()); + + expect(screen.getByText('Used 1 tool').closest('details')!.open).toBe(true); + expect(document.activeElement).toBe(summary); + expect(selection.toString()).toBe('Keep this question selected'); + expect( + within(messages()) + .getAllByRole('article') + .map(article => article.getAttribute('aria-label')) + ).toEqual(['Your question', 'Assistant answer']); + + selection.removeAllRanges(); + }); + + it('keeps an earlier identical question selected when another send overtakes its history refresh', async () => { + const source = fixture(); + const history = deferred(); + const originalGetMessages = source.api.getMessages; + vi.spyOn(source.api, 'getMessages').mockImplementationOnce(async id => { + await history.promise; + return originalGetMessages(id); + }); + await mountPanel(source); + await submit('Repeated question'); + + const summary = screen.getByText('Used 1 tool'); + summary.closest('details')!.open = true; + const selection = window.getSelection()!; + const range = document.createRange(); + range.selectNodeContents(screen.getByText('Repeated question')); + selection.removeAllRanges(); + selection.addRange(range); + + try { + await submit('Repeated question'); + + expect(within(messages()).getAllByText('Repeated question')).toHaveLength(2); + expect(selection.toString()).toBe('Repeated question'); + expect(within(messages()).getAllByText('Used 1 tool')[0].closest('details')!.open).toBe(true); + + await act(async () => history.resolve()); + + expect(within(messages()).getAllByText('Repeated question')).toHaveLength(2); + expect(selection.toString()).toBe('Repeated question'); + } finally { + await act(async () => history.resolve()); + selection.removeAllRanges(); + } + }); + + it('cancels deletion without calling the API, and removes the real fixture conversation only after confirmation', async () => { + const source = fixture(); + const remove = vi.spyOn(source.api, 'deleteConversation'); + await mountPanel(source); + await select('Revenue analysis'); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Revenue analysis' })); + + const dialog = screen.getByRole('dialog', { name: 'Delete conversation' }); + expect(remove).not.toHaveBeenCalled(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + + expect(screen.queryByRole('dialog')).toBeNull(); + expect(remove).not.toHaveBeenCalled(); + expect(screen.getByText('Revenue increased in all three regions, led by Europe.')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Revenue analysis' })); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete conversation' })); + await flush(); + + expect(remove).toHaveBeenCalledExactlyOnceWith('c1'); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.queryByText('Revenue increased in all three regions, led by Europe.')).toBeNull(); + + fireEvent.click(listTrigger()); + + expect(screen.queryByRole('button', { name: /^Revenue analysis/ })).toBeNull(); + expect((await source.api.listConversations()).map(item => item.id)).toEqual(['c2', 'c3']); + }); + + it('consumes modal Escape without deleting or bubbling to the shell', async () => { + const source = fixture(); + const remove = vi.spyOn(source.api, 'deleteConversation'); + const { onKeyDown } = await mountPanel(source); + await select('Revenue analysis'); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Revenue analysis' })); + + const cancel = within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' }); + expect(document.activeElement).toBe(cancel); + + fireEvent.keyDown(cancel, { key: 'Escape' }); + + expect(screen.queryByRole('dialog')).toBeNull(); + expect(remove).not.toHaveBeenCalled(); + expect(onKeyDown).not.toHaveBeenCalled(); + expect(screen.getByText('Revenue increased in all three regions, led by Europe.')).toBeTruthy(); + }); + + it('shows a delete rejection and retains the conversation rather than pretending it was removed', async () => { + const source = fixture(); + const remove = vi + .spyOn(source.api, 'deleteConversation') + .mockRejectedValueOnce(new Error('Cannot delete this conversation.')); + await mountPanel(source); + + fireEvent.click(listTrigger()); + fireEvent.click(screen.getByRole('button', { name: 'Delete Revenue analysis' })); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete conversation' })); + await flush(); + + expect(remove).toHaveBeenCalledExactlyOnceWith('c1'); + expect( + screen.getAllByRole('alert').some(alert => alert.textContent?.includes('Cannot delete this conversation.')) + ).toBe(true); + expect(screen.getByRole('button', { name: /^Revenue analysis/ })).toBeTruthy(); + expect((await source.api.listConversations()).some(item => item.id === 'c1')).toBe(true); + }); + + it('shows create failure, retains the draft, and retries creation only on an explicit send', async () => { + const source = fixture('create-failure'); + const create = vi.spyOn(source.api, 'createConversation'); + const run = vi.spyOn(source, 'run'); + await mountPanel(source); + + await submit('Retain this question'); + + expect(screen.getByRole('alert').textContent).toContain('could not create the conversation'); + expect(input().value).toBe('Retain this question'); + expect(create).toHaveBeenCalledTimes(1); + expect(run).not.toHaveBeenCalled(); + expect(send().matches(':disabled')).toBe(false); + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await flush(); + + expect(create).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenCalledExactlyOnceWith('c4', 'Retain this question', expect.any(AbortSignal)); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('retries a rejected first send using its confirmed conversation id without creating another conversation', async () => { + const source = fixture('send-rejection'); + const create = vi.spyOn(source.api, 'createConversation'); + const run = vi.spyOn(source, 'run'); + await mountPanel(source); + + await submit('Retry the same conversation'); + + expect(screen.getByRole('alert').textContent).toContain('could not accept this message'); + expect(input().value).toBe('Retry the same conversation'); + expect(send().matches(':disabled')).toBe(false); + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await flush(); + + expect(create).toHaveBeenCalledTimes(1); + expect(run.mock.calls.map(([id]) => id)).toEqual(['c4', 'c4']); + expect(screen.getAllByRole('article', { name: 'Your question' })).toHaveLength(1); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('keeps an unknown draft editable and reloads only on explicit recovery without automatically resending', async () => { + const source = fixture('unknown'); + const run = vi.spyOn(source, 'run'); + const getMessages = vi.spyOn(source.api, 'getMessages'); + await mountPanel(source); + + await submit('Was this accepted?'); + + expect(input().value).toBe('Was this accepted?'); + expect(input().disabled).toBe(false); + + fireEvent.change(input(), { target: { value: 'My next explicit question' } }); + + expect(input().value).toBe('My next explicit question'); + expect(send().matches(':disabled')).toBe(false); + expect(run).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: 'Reload conversation' })); + await flush(); + + expect(getMessages).toHaveBeenCalledExactlyOnceWith('c4'); + expect(screen.getByRole('article', { name: 'Your question' }).textContent).toContain('Was this accepted?'); + expect(input().value).toBe('My next explicit question'); + expect(send().matches(':disabled')).toBe(false); + expect(run).toHaveBeenCalledTimes(1); + + fireEvent.click(send()); + await flush(); + + expect(run).toHaveBeenCalledTimes(2); + expect(run.mock.calls[1][0]).toBe('c4'); + expect(run.mock.calls[1][1]).toBe('My next explicit question'); + }); + + it('allows an explicit normal Send immediately after an unknown outcome without a history prerequisite', async () => { + const source = fixture('unknown'); + const run = vi.spyOn(source, 'run'); + const create = vi.spyOn(source.api, 'createConversation'); + const getMessages = vi.spyOn(source.api, 'getMessages'); + await mountPanel(source); + + await submit('First question with unknown acceptance'); + + expect(run).toHaveBeenCalledTimes(1); + expect(getMessages).not.toHaveBeenCalled(); + + fireEvent.change(input(), { target: { value: 'Explicit next question' } }); + + expect(send().matches(':disabled')).toBe(false); + + fireEvent.click(send()); + await flush(); + + expect(create).toHaveBeenCalledTimes(1); + expect(run.mock.calls.map(([id, question]) => [id, question])).toEqual([ + ['c4', 'First question with unknown acceptance'], + ['c4', 'Explicit next question'] + ]); + expect(screen.getByRole('table')).toBeTruthy(); + }); + + it('retries the edited draft after an accepted failure without requiring a history reload', async () => { + const source = fixture('run-failure'); + const run = vi.spyOn(source, 'run'); + const getMessages = vi.spyOn(source.api, 'getMessages'); + await mountPanel(source); + + await submit('Read the revenue data'); + + expect(screen.getAllByRole('alert').some(alert => alert.textContent?.includes('stopped while reading'))).toBe(true); + expect(input().value).toBe('Read the revenue data'); + expect(send().matches(':disabled')).toBe(false); + expect(run).toHaveBeenCalledTimes(1); + expect(getMessages).not.toHaveBeenCalled(); + + const failedAnswer = within(messages()).getByRole('article', { name: /^Assistant answer$/ }); + + fireEvent.change(input(), { target: { value: 'Read only the Europe revenue data' } }); + + expect(failedAnswer.isConnected).toBe(true); + expect(failedAnswer.textContent).toContain('stopped while reading'); + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await flush(); + + expect(run).toHaveBeenCalledTimes(2); + expect(run.mock.calls.map(([id, question]) => [id, question])).toEqual([ + ['c4', 'Read the revenue data'], + ['c4', 'Read only the Europe revenue data'] + ]); + expect(within(messages()).getByText('Read only the Europe revenue data', { exact: true })).toBeTruthy(); + expect(screen.getByRole('table')).toBeTruthy(); + expect(input().value).toBe(''); + }); + + it('uses the current source for answer recovery after the panel source changes', async () => { + const first = fixture('run-failure'); + const second = fixture('run-failure'); + const firstRun = vi.spyOn(first, 'run'); + const secondRun = vi.spyOn(second, 'run'); + const view = render(); + await flush(); + await submit('Question for the first source'); + + view.rerender(); + await flush(); + await submit('Question for the second source'); + fireEvent.change(input(), { target: { value: 'Retry in the second source' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await flush(); + + expect(firstRun).toHaveBeenCalledTimes(1); + expect(secondRun.mock.calls.map(([, question]) => question)).toEqual([ + 'Question for the second source', + 'Retry in the second source' + ]); + expect(within(messages()).getByText('Retry in the second source', { exact: true })).toBeTruthy(); + expect(within(messages()).queryByText('Question for the first source', { exact: true })).toBeNull(); + expect(screen.getByRole('table')).toBeTruthy(); + }); + + it('keeps a late A stream out of B and returns to A without starting a second consumer', async () => { + const harness = streamingFixture(); + const getMessages = vi.spyOn(harness.source.api, 'getMessages'); + await mountPanel(harness.source); + await select('Revenue analysis'); + await submit('Question for A'); + await select('Notebook summary'); + fireEvent.change(input(), { target: { value: 'Question for B' } }); + input().focus(); + const focusedComposer = input(); + + await act(async () => harness.firstDelta.resolve()); + + expect(screen.queryByText('Only revenue A')).toBeNull(); + expect(screen.getByText('This notebook compares monthly revenue across regions.')).toBeTruthy(); + expect(input().value).toBe('Question for B'); + expect(document.activeElement).toBe(focusedComposer); + + const loads = getMessages.mock.calls.length; + await select('Revenue analysis'); + + expect(screen.getByText('Only revenue A')).toBeTruthy(); + expect(harness.run).toHaveBeenCalledTimes(1); + expect(getMessages).toHaveBeenCalledTimes(loads); + + await act(async () => { + harness.secondDelta.resolve(); + harness.finish.resolve(); + }); + }); + + it('disables the composer for read-only history without interpreting it as an active run', async () => { + const source = fixture(); + const run = vi.spyOn(source, 'run'); + await mountPanel(source); + + await select('Shared analysis'); + + expect(screen.getByText('This shared conversation can be read, but not changed.')).toBeTruthy(); + expect(input().disabled).toBe(true); + expect(send().matches(':disabled')).toBe(true); + expect(screen.queryByRole('button', { name: 'Delete Shared analysis' })).toBeNull(); + expect(screen.queryByText('Thinking…')).toBeNull(); + + fireEvent.submit(input().form!); + + expect(run).not.toHaveBeenCalled(); + }); + + it('keeps a draft blocked after an initial history failure until explicit reload succeeds without resending', async () => { + const source = fixture(); + const initial = deferred(); + const retry = deferred(); + const originalGetMessages = source.api.getMessages; + const getMessages = vi + .spyOn(source.api, 'getMessages') + .mockImplementationOnce(async () => { + await initial.promise; + throw new Error('Saved history is unavailable.'); + }) + .mockImplementationOnce(async id => { + await retry.promise; + return originalGetMessages(id); + }); + const run = vi.spyOn(source, 'run'); + await mountPanel(source); + await select('Revenue analysis'); + + fireEvent.change(input(), { target: { value: 'Keep this draft after the failed lookup' } }); + await act(async () => initial.resolve()); + + expect(screen.getByRole('alert').textContent).toContain('Saved history is unavailable.'); + expect(input().value).toBe('Keep this draft after the failed lookup'); + expect(input().disabled).toBe(false); + expect(input().readOnly).toBe(false); + expect(send().matches(':disabled')).toBe(true); + + fireEvent.change(input(), { target: { value: 'Edited question after lookup failure' } }); + fireEvent.click(send()); + fireEvent.keyDown(input(), { key: 'Enter' }); + fireEvent.submit(input().form!); + await flush(); + + expect(input().value).toBe('Edited question after lookup failure'); + expect(getMessages.mock.calls).toEqual([['c1']]); + expect(run).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Reload conversation' })); + await flush(); + + expect(getMessages.mock.calls).toEqual([['c1'], ['c1']]); + expect(input().value).toBe('Edited question after lookup failure'); + expect(send().matches(':disabled')).toBe(true); + expect(run).not.toHaveBeenCalled(); + + fireEvent.keyDown(input(), { key: 'Enter' }); + fireEvent.submit(input().form!); + await flush(); + + expect(run).not.toHaveBeenCalled(); + + await act(async () => retry.resolve()); + + expect(screen.getByText('Revenue increased in all three regions, led by Europe.')).toBeTruthy(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(input().value).toBe('Edited question after lookup failure'); + expect(send().matches(':disabled')).toBe(false); + expect(run).not.toHaveBeenCalled(); + + fireEvent.click(send()); + await flush(); + + expect(run).toHaveBeenCalledExactlyOnceWith('c1', 'Edited question after lookup failure', expect.any(AbortSignal)); + }); + + it('confirms deletion of an active conversation and lets the API decide the result', async () => { + const harness = streamingFixture(); + const remove = vi + .spyOn(harness.source.api, 'deleteConversation') + .mockRejectedValueOnce(new Error('Question active.')); + await mountPanel(harness.source); + await select('Revenue analysis'); + await submit('Keep A running'); + await select('Notebook summary'); + + fireEvent.click(listTrigger()); + fireEvent.click(screen.getByRole('button', { name: 'Delete Revenue analysis' })); + + expect(screen.getByRole('dialog')).toBeTruthy(); + expect(remove).not.toHaveBeenCalled(); + + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete conversation' })); + await flush(); + + expect(remove).toHaveBeenCalledExactlyOnceWith('c1'); + expect(within(screen.getByRole('dialog')).getByRole('alert').textContent).toContain('Question active.'); + + await act(async () => { + harness.firstDelta.resolve(); + harness.secondDelta.resolve(); + harness.finish.resolve(); + }); + }); + + it('discards a confirmation when the source changes rather than applying it to the same id in another source', async () => { + const first = fixture(); + const second = fixture(); + const remove = vi.spyOn(second.api, 'deleteConversation'); + const view = render(); + await flush(); + await select('Revenue analysis'); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Revenue analysis' })); + + expect(screen.getByRole('dialog')).toBeTruthy(); + + view.rerender(); + await flush(); + + expect(screen.queryByRole('dialog')).toBeNull(); + + fireEvent.click(listTrigger()); + + expect(screen.getByRole('button', { name: /^Revenue analysis/ })).toBeTruthy(); + expect(remove).not.toHaveBeenCalled(); + }); + + it('ignores an old deletion continuation without clearing a new source confirmation or its cancel action', async () => { + const first = fixture(); + const second = fixture(); + const gate = deferred(); + const remove = vi.spyOn(second.api, 'deleteConversation'); + const view = render(); + await flush(); + + await submit('Temporary conversation'); + fireEvent.click(screen.getByRole('button', { name: 'Delete New conversation' })); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete conversation' })); + await flush(); + + await select('Notebook summary'); + fireEvent.click(screen.getByRole('button', { name: 'Delete Notebook summary' })); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete conversation' })); + await flush(); + + vi.spyOn(first.api, 'deleteConversation').mockImplementationOnce(() => gate.promise); + await select('Revenue analysis'); + fireEvent.click(screen.getByRole('button', { name: 'Delete Revenue analysis' })); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete conversation' })); + + view.rerender(); + await flush(); + await select('Revenue analysis'); + fireEvent.click(screen.getByRole('button', { name: 'Delete Revenue analysis' })); + + await act(async () => gate.resolve()); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).queryByRole('alert')).toBeNull(); + const cancel = within(dialog).getByRole('button', { name: 'Cancel' }); + expect(cancel.matches(':disabled')).toBe(false); + + fireEvent.click(cancel); + + expect(screen.queryByRole('dialog')).toBeNull(); + expect(remove).not.toHaveBeenCalled(); + expect(screen.getByText('Revenue increased in all three regions, led by Europe.')).toBeTruthy(); + }); + + it('keeps the initial history draft editable but blocks every submission until the read succeeds', async () => { + const source = fixture(); + const history = deferred(); + const originalGetMessages = source.api.getMessages; + vi.spyOn(source.api, 'getMessages').mockImplementationOnce(async id => { + await history.promise; + return originalGetMessages(id); + }); + const run = vi.spyOn(source, 'run'); + await mountPanel(source); + await select('Revenue analysis'); + const field = input(); + + expect(screen.getByText('Loading conversation…')).toBeTruthy(); + expect(field.disabled).toBe(false); + expect(field.readOnly).toBe(false); + expect(screen.queryByText('Sending question…')).toBeNull(); + expect(screen.queryByText('Thinking…')).toBeNull(); + + fireEvent.change(field, { target: { value: 'Question during history lookup' } }); + + expect(field.value).toBe('Question during history lookup'); + expect(send().matches(':disabled')).toBe(true); + + fireEvent.click(send()); + fireEvent.keyDown(field, { key: 'Enter' }); + fireEvent.submit(field.form!); + await flush(); + + expect(run).not.toHaveBeenCalled(); + expect(field.value).toBe('Question during history lookup'); + expect(screen.getByText('Loading conversation…')).toBeTruthy(); + + fireEvent.change(field, { target: { value: 'Edited question before history arrives' } }); + await act(async () => history.resolve()); + + expect(input()).toBe(field); + expect(field.value).toBe('Edited question before history arrives'); + expect(screen.getByText('Revenue increased in all three regions, led by Europe.')).toBeTruthy(); + expect(send().matches(':disabled')).toBe(false); + expect(run).not.toHaveBeenCalled(); + + fireEvent.keyDown(field, { key: 'Enter' }); + await flush(); + + expect(run).toHaveBeenCalledExactlyOnceWith( + 'c1', + 'Edited question before history arrives', + expect.any(AbortSignal) + ); + }); + + it.each(['Cancel', 'Escape'])( + 'dismisses a pending delete with %s without cancelling the API or changing a later confirmation', + async dismissal => { + const source = fixture(); + const gate = deferred(); + const originalDelete = source.api.deleteConversation; + const remove = vi.spyOn(source.api, 'deleteConversation').mockImplementationOnce(async id => { + await gate.promise; + return originalDelete(id); + }); + await mountPanel(source); + await select('Revenue analysis'); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Revenue analysis' })); + const confirm = within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete conversation' }); + fireEvent.click(confirm); + fireEvent.click(confirm); + + expect(remove).toHaveBeenCalledExactlyOnceWith('c1'); + + const cancel = within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' }); + if (dismissal === 'Escape') fireEvent.keyDown(cancel, { key: 'Escape' }); + else fireEvent.click(cancel); + + expect(screen.queryByRole('dialog')).toBeNull(); + + await select('Notebook summary'); + fireEvent.click(screen.getByRole('button', { name: 'Delete Notebook summary' })); + + await act(async () => gate.resolve()); + + const nextDialog = screen.getByRole('dialog'); + expect(within(nextDialog).getByText(/Delete “Notebook summary”/)).toBeTruthy(); + expect(within(nextDialog).queryByRole('alert')).toBeNull(); + expect(within(nextDialog).getByRole('button', { name: 'Delete conversation' }).matches(':disabled')).toBe(false); + + fireEvent.click(within(nextDialog).getByRole('button', { name: 'Delete conversation' })); + await flush(); + + expect(remove.mock.calls).toEqual([['c1'], ['c2']]); + expect(screen.queryByRole('dialog')).toBeNull(); + + fireEvent.click(listTrigger()); + + expect(screen.queryByRole('button', { name: /^Revenue analysis/ })).toBeNull(); + expect(screen.queryByRole('button', { name: /^Notebook summary/ })).toBeNull(); + } + ); + + it('closes a reopened confirmation when its pending deletion succeeds for the same conversation', async () => { + const source = fixture(); + const gate = deferred(); + const originalDelete = source.api.deleteConversation; + const remove = vi.spyOn(source.api, 'deleteConversation').mockImplementationOnce(async id => { + await gate.promise; + return originalDelete(id); + }); + await mountPanel(source); + await select('Revenue analysis'); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Revenue analysis' })); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete conversation' })); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' })); + fireEvent.click(screen.getByRole('button', { name: 'Delete Revenue analysis' })); + + expect(screen.getByRole('dialog')).toBeTruthy(); + + await act(async () => gate.resolve()); + + expect(screen.queryByRole('dialog')).toBeNull(); + expect(remove).toHaveBeenCalledExactlyOnceWith('c1'); + + fireEvent.click(listTrigger()); + + expect(screen.queryByRole('button', { name: /^Revenue analysis/ })).toBeNull(); + }); + + it.each(['failed', 'connection'])( + 'retains the previous %s answer outcome when the next question is rejected', + async outcome => { + const source = fixture(); + let first = true; + const failure = outcome === 'failed' ? 'The first run failed.' : new AssistantConnectionError().message; + source.run = async function* (): AsyncIterable { + if (!first) { + yield { type: 'run.failed', message: 'The next question was rejected.' }; + return; + } + first = false; + yield { type: 'run.started' }; + yield { type: 'message.delta', messageId: 'partial-answer', delta: 'Partial answer' }; + yield { type: 'tool_call.started', toolCallId: 'unfinished-tool', name: 'read_notebook' }; + if (outcome === 'connection') throw new AssistantConnectionError(); + yield { type: 'run.failed', message: failure }; + }; + await mountPanel(source); + await select('Revenue analysis'); + await submit('First question'); + + await submit('Next question'); + + const retained = screen.getByText('Partial answer').closest('article')!; + expect(within(retained).getByText(/status unknown/)).toBeTruthy(); + expect(within(retained).getByRole('alert').textContent).toContain(failure); + expect(screen.getByText('The next question was rejected.')).toBeTruthy(); + } + ); + + it('keeps B deletion failure in its dialog when a dismissed A deletion fails later', async () => { + const source = fixture(); + const a = Promise.withResolvers(); + const b = Promise.withResolvers(); + vi.spyOn(source.api, 'deleteConversation').mockImplementation(id => (id === 'c1' ? a.promise : b.promise)); + await mountPanel(source); + fireEvent.click(listTrigger()); + fireEvent.click(screen.getByRole('button', { name: 'Delete Revenue analysis' })); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete conversation' })); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' })); + fireEvent.click(screen.getByRole('button', { name: 'Delete Notebook summary' })); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete conversation' })); + + await act(async () => b.reject(new Error('B deletion denied'))); + + expect(within(screen.getByRole('dialog')).getByRole('alert').textContent).toContain('B deletion denied'); + + await act(async () => a.reject(new Error('A is still running'))); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByText(/Delete “Notebook summary”/)).toBeTruthy(); + expect(within(dialog).getByRole('alert').textContent).toContain('B deletion denied'); + expect(within(dialog).queryByText('A is still running')).toBeNull(); + }); + + it('keeps initial-history readiness and drafts separate when switching conversations', async () => { + const source = fixture(); + const history = deferred(); + const getMessages = source.api.getMessages; + vi.spyOn(source.api, 'getMessages').mockImplementationOnce(async id => { + await history.promise; + return getMessages(id); + }); + const run = vi.spyOn(source, 'run'); + await mountPanel(source); + await select('Revenue analysis'); + fireEvent.change(input(), { target: { value: 'Keep the pending A draft' } }); + + expect(send().matches(':disabled')).toBe(true); + + await select('Notebook summary'); + fireEvent.change(input(), { target: { value: 'Question for loaded B' } }); + + expect(send().matches(':disabled')).toBe(false); + + fireEvent.click(send()); + await flush(); + await act(async () => history.resolve()); + + expect(run).toHaveBeenCalledExactlyOnceWith('c2', 'Question for loaded B', expect.any(AbortSignal)); + expect(within(messages()).getByText('Question for loaded B', { exact: true })).toBeTruthy(); + expect(within(messages()).queryByText('Keep the pending A draft', { exact: true })).toBeNull(); + expect(input().value).toBe(''); + + await select('Revenue analysis'); + + expect(input().value).toBe('Keep the pending A draft'); + expect(send().matches(':disabled')).toBe(false); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/AssistantConversationPanel.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/AssistantConversationPanel.stories.tsx new file mode 100644 index 00000000000..63d2af818f7 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/AssistantConversationPanel.stories.tsx @@ -0,0 +1,236 @@ +/* + * 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 { useMemo } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import { createAssistantFixture, type AssistantFixtureScenario } from '../fixtures/assistantFixture'; +import { + ASSISTANT_DEMO_CONVERSATIONS, + ASSISTANT_HISTORY_CONVERSATIONS, + FIXTURE_NOW +} from '../fixtures/assistantFixtureData'; +import { AssistantConversationPanel } from './AssistantConversationPanel'; + +type PanelDemonstration = + | 'ask' + | 'completed-answer' + | 'two-page-history' + | 'conversation-switching' + | 'creation-failure' + | 'send-rejected' + | 'acceptance-unknown' + | 'accepted-run-failure'; + +const failureScenarios: Partial> = { + 'creation-failure': 'create-failure', + 'send-rejected': 'send-rejection', + 'acceptance-unknown': 'unknown', + 'accepted-run-failure': 'run-failure' +}; + +const FixturePanel = ({ demonstration }: { demonstration: PanelDemonstration }) => { + const source = useMemo( + () => + createAssistantFixture({ + conversations: + demonstration === 'two-page-history' ? ASSISTANT_HISTORY_CONVERSATIONS : ASSISTANT_DEMO_CONVERSATIONS, + now: FIXTURE_NOW, + scenario: failureScenarios[demonstration] ?? 'normal', + delayMs: demonstration === 'conversation-switching' ? 500 : 120 + }), + [demonstration] + ); + + return ( +
+ +
+ ); +}; + +const meta = { + title: 'Assistant/ConversationPanel', + component: FixturePanel, + args: { demonstration: 'ask' }, + argTypes: { demonstration: { control: false, table: { disable: true } } } +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const ask = async (canvasElement: HTMLElement, question: string) => { + const canvas = within(canvasElement); + + await userEvent.type(canvas.getByRole('textbox', { name: 'Question' }), question); + await userEvent.click(canvas.getByRole('button', { name: 'Send' })); + + return canvas; +}; + +const selectConversation = async (canvasElement: HTMLElement, title: RegExp) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByRole('button', { name: 'Open conversations' })); + await userEvent.click(await canvas.findByRole('button', { name: title })); +}; + +export const AskInThePanel: Story = { + parameters: { + docs: { + description: { + story: + 'Manual interactive demo with compact saved conversations. Type a question and Send to create a conversation and stream an answer. New stays local until the first send.' + } + } + } +}; + +export const CompletedAnswer: Story = { + args: { demonstration: 'completed-answer' }, + parameters: { + docs: { + description: { + story: 'Automatically asks a question and waits for the completed Markdown answer and table.' + } + } + }, + play: async ({ canvasElement }) => { + const canvas = await ask(canvasElement, 'Explain the revenue results.'); + const table = await canvas.findByRole('table', {}, { timeout: 10000 }); + const answer = table.closest('article')!; + await waitFor(() => expect(answer).not.toHaveAttribute('aria-busy', 'true'), { timeout: 10000 }); + await expect(table).toBeVisible(); + } +}; + +export const TwoPageHistory: Story = { + args: { demonstration: 'two-page-history' }, + parameters: { + docs: { + description: { + story: + 'Automatically opens Revenue analysis and scrolls its message viewport to the top to load the older page. The earlier question and both parts of the boundary-split answer are then shown together.' + } + } + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await selectConversation(canvasElement, /^Revenue analysis/); + await canvas.findByText('Revenue increased in all three regions, led by Europe.'); + + const viewport = canvas.getByRole('region', { name: 'Conversation messages' }); + await waitFor(() => expect(viewport).not.toHaveAttribute('aria-busy', 'true')); + viewport.scrollTo({ top: 0 }); + await expect(await canvas.findByText('How did revenue change by region?', {}, { timeout: 10000 })).toBeVisible(); + + const earlierPart = await canvas.findByText('I checked the regional revenue paragraph.'); + const joinedAnswer = earlierPart.closest('article')!; + await expect(earlierPart).toBeVisible(); + await expect( + within(joinedAnswer).getByText('Revenue increased in all three regions, led by Europe.') + ).toBeVisible(); + } +}; + +export const ConversationSwitching: Story = { + args: { demonstration: 'conversation-switching' }, + parameters: { + docs: { + description: { + story: + 'Sends in Revenue analysis, switches to Notebook summary during the answer, then returns to the completed result.' + } + } + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await selectConversation(canvasElement, /^Revenue analysis/); + await canvas.findByText('Compare Europe’s order volume with its average order value.'); + + await ask(canvasElement, 'Explain revenue while I check the notebook summary.'); + await canvas.findByText('Thinking…', {}, { timeout: 10000 }); + + await selectConversation(canvasElement, /^Notebook summary/); + await expect(await canvas.findByText('This notebook compares monthly revenue across regions.')).toBeVisible(); + + await selectConversation(canvasElement, /^Revenue analysis/); + await expect(await canvas.findByText('Explain revenue while I check the notebook summary.')).toBeVisible(); + const table = await canvas.findByRole('table', {}, { timeout: 10000 }); + await waitFor(() => expect(table.closest('article')).not.toHaveAttribute('aria-busy', 'true'), { timeout: 10000 }); + await expect(table).toBeVisible(); + } +}; + +export const CreationFailure: Story = { + args: { demonstration: 'creation-failure' }, + parameters: { + docs: { + description: { + story: 'Submits a first question and stops at conversation creation failure, ready for an explicit retry.' + } + } + }, + play: async ({ canvasElement }) => { + const canvas = await ask(canvasElement, 'Keep this question if creation fails.'); + await expect(await canvas.findByRole('alert', {}, { timeout: 10000 })).toBeVisible(); + } +}; + +export const SendRejectedAfterCreation: Story = { + args: { demonstration: 'send-rejected' }, + parameters: { + docs: { + description: { + story: + 'Creates a conversation and stops when its first send is rejected before acceptance. No retry is submitted.' + } + } + }, + play: async ({ canvasElement }) => { + const canvas = await ask(canvasElement, 'Keep this question if the send is rejected.'); + await expect(await canvas.findByRole('alert', {}, { timeout: 10000 })).toBeVisible(); + } +}; + +export const AcceptanceUnknown: Story = { + args: { demonstration: 'acceptance-unknown' }, + parameters: { + docs: { + description: { + story: + 'Stops at unknown acceptance, showing the warning and explicit Reload action without reloading or resending.' + } + } + }, + play: async ({ canvasElement }) => { + const canvas = await ask(canvasElement, 'Was this question accepted?'); + await expect(await canvas.findByRole('button', { name: 'Reload conversation' }, { timeout: 10000 })).toBeVisible(); + await expect(canvas.getAllByRole('alert')[0]).toBeVisible(); + } +}; + +export const AcceptedRunFailure: Story = { + args: { demonstration: 'accepted-run-failure' }, + parameters: { + docs: { + description: { + story: 'Stops after an accepted run fails, leaving the accepted question and inline answer failure visible.' + } + } + }, + play: async ({ canvasElement }) => { + const canvas = await ask(canvasElement, 'Read the revenue data.'); + await expect(await canvas.findByRole('article', { name: 'Your question' })).toBeVisible(); + const answer = await canvas.findByRole('article', { name: 'Assistant answer' }); + await expect(await within(answer).findByRole('alert', {}, { timeout: 10000 })).toBeVisible(); + } +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/AssistantConversationPanel.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/AssistantConversationPanel.tsx new file mode 100644 index 00000000000..a0b68e71613 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/AssistantConversationPanel.tsx @@ -0,0 +1,200 @@ +/* + * 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 { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { AssistantAnswerError } from '@/entities/assistant/ui/AssistantAnswerError'; +import { AssistantComposer } from '@/entities/assistant/ui/AssistantComposer'; +import { ConversationHeader } from '@/entities/assistant/ui/ConversationHeader'; +import { ConversationList } from '@/entities/assistant/ui/ConversationList'; +import { useDeleteConfirmation } from '../lib/useDeleteConfirmation'; +import type { AssistantDataSource } from '../model/assistantDataSource'; +import { useAssistantPanel } from '../model/useAssistantPanel'; +import { ConversationMessages } from './ConversationMessages'; +import { DeleteConversationDialog } from './DeleteConversationDialog'; +import * as styles from './AssistantConversationPanel.css'; + +export interface AssistantConversationPanelProps { + source: AssistantDataSource; + now?: number; +} + +const ignoreSearchChange = () => undefined; + +export const AssistantConversationPanel = ({ source, now }: AssistantConversationPanelProps) => { + const panel = useAssistantPanel(source); + const { entry, send: sendQuestion, reload: reloadHistory } = panel; + + const [listOpen, setListOpen] = useState(false); + const listTrigger = useRef(null); + const back = useRef(null); + const navigation = useRef({ scope: source, open: false }); + + const fallbackFocus = useCallback(() => (listOpen ? back.current : listTrigger.current), [listOpen]); + const confirmation = useDeleteConfirmation({ + scope: source, + conversations: panel.conversations, + listLoading: panel.listLoading, + deletingConversationIds: panel.deletingConversationIds, + onDelete: panel.deleteConversation, + fallbackFocus + }); + + useLayoutEffect(() => { + if (navigation.current.scope !== source) { + navigation.current = { scope: source, open: false }; + setListOpen(false); + return; + } + + if (confirmation.target) return; + + const wasOpen = navigation.current.open; + navigation.current.open = listOpen; + if (listOpen && !wasOpen) back.current?.focus(); + else if (!listOpen && wasOpen) listTrigger.current?.focus(); + }, [source, listOpen, confirmation.target]); + + const closeList = () => setListOpen(false); + + const createDraft = () => { + panel.newConversation(); + closeList(); + }; + + const busy = entry.phase === 'creating' || entry.phase === 'pending' || entry.phase === 'running'; + + const retrySend = useCallback(() => { + void sendQuestion(); + }, [sendQuestion]); + const retryReload = useCallback(() => { + void reloadHistory(); + }, [reloadHistory]); + + const recoveryActions = { send: retrySend, reload: retryReload }; + const retry = entry.failure?.recovery ? recoveryActions[entry.failure.recovery] : undefined; + const answerRetry = entry.answerRecovery ? recoveryActions[entry.answerRecovery] : undefined; + + return ( +
{ + if (listOpen && event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + closeList(); + } + }} + > +
+ + + {listOpen && ( +
+
+ +
+ {panel.listLoading && ( +

+ Loading conversations… +

+ )} + {panel.listError && ( +

+ {panel.listError} +

+ )} + { + panel.select(id); + closeList(); + }} + onCreate={createDraft} + newDisabled={panel.creatingConversation} + onDelete={confirmation.request} + onSearchTermChange={ignoreSearchChange} + now={now} + /> +
+ )} +
+ + {confirmation.target && ( + { + void confirmation.confirm(); + }} + /> + )} +
+ ); +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/ConversationMessages.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/ConversationMessages.tsx new file mode 100644 index 00000000000..a777901bb94 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/ConversationMessages.tsx @@ -0,0 +1,117 @@ +/* + * 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 { useMemo } from 'react'; +import { AssistantAnswer } from '@/entities/assistant/ui/AssistantAnswer'; +import { AssistantMessageItem } from '@/entities/assistant/ui/AssistantMessageItem'; +import { MarkdownAnswer } from '@/entities/assistant/ui/MarkdownAnswer'; +import { useConversationScroll } from '../lib/useConversationScroll'; +import type { AssistantPanelEntry } from '../model/assistantPanelState'; +import * as styles from './AssistantConversationPanel.css'; + +const renderMarkdown = (content: string) => ( +
+ +
+); + +export const ConversationMessages = ({ + scope, + entry, + visible, + loadEarlier, + onRetry +}: { + scope: object; + entry: AssistantPanelEntry; + visible: boolean; + loadEarlier: () => Promise; + onRetry?: () => void; +}) => { + const { messages, messageKeys, localAnswerViews, answer, answerKey } = entry; + + const messageItems = useMemo(() => { + const items = messages.map(message => { + const key = messageKeys.get(message.id) ?? message.id; + return ( +
+ {message.role === 'user' ? ( + + ) : ( + + )} +
+ ); + }); + + if (answer) { + items.push( +
+ +
+ ); + } + + return items; + }, [messages, messageKeys, localAnswerViews, answer, answerKey, onRetry]); + + const completedMessage = answer?.status === 'done' ? answer.message : undefined; + const rowSegments = useMemo(() => { + const segments = new Map(); + for (const message of messages) { + if (message.role === 'assistant') segments.set(messageKeys.get(message.id) ?? message.id, message.segments); + } + + if (answerKey && completedMessage) segments.set(answerKey, completedMessage.segments); + return segments; + }, [messages, messageKeys, answerKey, completedMessage]); + + const { viewport, content, onScroll } = useConversationScroll({ + scope, + conversationKey: entry.key, + rowSegments, + visible, + loadEarlier + }); + + return ( +
+
+ {entry.loadingEarlier && ( +

+ Loading earlier messages… +

+ )} + {entry.loading && messages.length === 0 && !answer && ( +

+ Loading conversation… +

+ )} + {!entry.loading && entry.loaded && messages.length === 0 && !answer && ( +

Ask a question about this notebook to start a conversation.

+ )} + {messageItems} +
+
+ ); +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/DeleteConversationDialog.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/DeleteConversationDialog.tsx new file mode 100644 index 00000000000..ae7382d9953 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/ui/DeleteConversationDialog.tsx @@ -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 { useId, useLayoutEffect, useRef, type KeyboardEvent } from 'react'; +import * as styles from './AssistantConversationPanel.css'; + +interface DeleteConversationDialogProps { + title: string; + deleting: boolean; + error?: string; + onCancel: () => void; + onConfirm: () => void; +} + +export const DeleteConversationDialog = ({ + title, + deleting, + error, + onCancel, + onConfirm +}: DeleteConversationDialogProps) => { + const titleId = useId(); + const descriptionId = useId(); + const dialog = useRef(null); + const cancel = useRef(null); + + useLayoutEffect(() => { + if (deleting) dialog.current?.focus(); + else cancel.current?.focus(); + }, [deleting]); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onCancel(); + } else if (event.key === 'Tab') { + const buttons = dialog.current?.querySelectorAll('button:not(:disabled)'); + if (!buttons?.length) { + event.preventDefault(); + dialog.current?.focus(); + return; + } + + const first = buttons[0]; + const last = buttons[buttons.length - 1]; + if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog.current)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + }; + + return ( +
+
+

+ Delete conversation +

+

+ Delete “{title}”? This conversation and its messages will be removed. +

+ {error && ( +

+ {error} +

+ )} + {deleting && ( +

+ Deleting conversation… +

+ )} +
+ + +
+
+
+ ); +};