This is an automated email from the ASF dual-hosted git repository.
voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new f801b2e4c2 [ZEPPELIN-6745] Add AI Assistant message and answer state UI
f801b2e4c2 is described below
commit f801b2e4c2f0fae5e5e47ed6e12a2eda9069fd14
Author: 김예나 <[email protected]>
AuthorDate: Tue Oct 20 22:50:00 2026 +0900
[ZEPPELIN-6745] Add AI Assistant message and answer state UI
### What is this PR for?
Add the AI Assistant message and answer state UI: user and Assistant
messages are shown distinctly, and an answer displays its loading, streaming,
tool-use, completed and error states. Everything is driven by fixture props.
There is no server, transport or panel state. The components live in
`projects/zeppelin-react/src/entities/assistant/ui`.
This PR adds new files under `entities/assistant/ui` and removes the
temporary `Assistant/Setup` story. It builds on the shared contract from
ZEPPELIN-6741 (#5561) and the Markdown answer from ZEPPELIN-6744 (#5570), both
on `master`.
Commits:
1. **Message item and answer view types**
* `AssistantMessageItem` renders a question or an answer:
* A question is a right-aligned bubble, always shown as typed. It
never goes through the content renderer.
* An answer is a full-width body under an "AI / Assistant" label.
While it streams, it is marked `aria-busy` and shows a caret where the text
ends.
* `AssistantAnswerView` is the display union (`loading` / `streaming` /
`done` / `error`). It reuses `AssistantAnswerMessage` and
`AssistantToolCallRef` from the ZEPPELIN-6741 contract.
2. **Answer status, tool log and error displays**
* `AssistantAnswer` renders one view at a time. Each state fully
replaces the last.
* The progress line (`role="status"`) reads "Thinking…", "Writing
answer…" or "Running \<tool\>…".
* `AssistantToolLog` is open while a tool runs and collapses once the
answer completes. A tool without a name is labelled "Tool".
* `AssistantAnswerError` is an antd `Alert` (`role="alert"`):
* `failed` (`run.failed`) and `stream` (`AssistantStreamError`) are
errors with **Retry**.
* `connection` (`AssistantConnectionError`) is a warning with **Reload
conversation**, because the answer may have finished on the server.
* With no `onRetry`, no button is shown.
* A partial answer that arrived before the error is kept.
3. **Stories**
* `Assistant/Message`: User, CompletedReply, StreamingReply,
LongContent, Conversation.
* `Assistant/Status`: Loading, Streaming, ToolRunning, Completed,
CompletedWithTools, Failed, StreamError, ConnectionLost, ErrorWithoutRetry.
* `Assistant/Status/StateSwitcher` switches the fixture from the
Controls panel, so you can check that the states stay consistent in both themes.
4. **Review: remove the temporary theme check story**
* `Assistant/Setup › TemporaryThemeCheck` (`AssistantScope.stories.tsx`)
was a placeholder until the Assistant UI stories landed. Nothing else
referenced it.
5. **Review: align the answer UI with segmented history** (by
<at>voidmatcha)
* A completed answer is an `AssistantAnswerMessage` with ordered
`segments`. Its text joins the rounds in order, and its tool log lists the
tools from every round.
* Tools still marked running when an error arrives are shown as "status
unknown", not as running or done.
6. **Review: render answers as Markdown by default**
* `renderContent` now defaults to `MarkdownAnswer` from ZEPPELIN-6744. A
parent can still pass its own, for example one that resolves paragraph
references.
* `MarkdownAnswer` wraps the text in blocks, so the streaming caret is
now drawn inside the last block (or the last item of a closing list). It stays
on the line where the text ends instead of dropping below it.
Styling uses `<at>vanilla-extract/css` through the Assistant theme contract
only, so light and dark need no per-component rules. The HTML/CSS screens in
`voidmatcha/assistant-storybook-share` were used as a design reference. The
code is written from scratch.
**Out of scope** (per the issue / ZEPPELIN-6748): run event → view
derivation, panel mounting in `AssistantWorkspace`, real tool calls, approval,
persistence and WebSocket handling. Showing failed tool calls (raised in
review) is tracked in ZEPPELIN-6754.
### What type of PR is it?
Feature
### Todos
* [x] Use the ZEPPELIN-6744 Markdown component as the default
`renderContent` once it merges
### What is the Jira issue?
* https://issues.apache.org/jira/browse/ZEPPELIN-6745
### How should this be tested?
In `zeppelin-web-angular/projects/zeppelin-react`:
* `npm ci`, `npm test` (254 passed), `npm run typecheck`, `npm run lint`
and `npm run build:storybook` all pass.
* `typecheck` needs `npm ci` in `zeppelin-web-angular` first, because the
SDK resolves `rxjs` from there.
* The new files also pass the frontend's `prettier --check`.
* `npm run storybook`: both themes checked manually for every story above,
including StateSwitcher.
* The specs were mutation-checked: making the connection error say "Retry",
keeping the progress line on error, dropping the "Tool" fallback, rendering a
question through `renderContent`, keeping the tool log open, or going back to a
plain-text default each fails exactly the intended test.
**`npm run test:storybook`:**
* With `--workers=1`, all 68 tests pass.
* With the default config (3 workers), 1–5 tests time out between runs.
* The traces show asset requests to the `python3 -m http.server` web
server failing (status -1) and a blank page.
* On the earlier temporary base (`4599ad2d9`), its own story alone
reproduced this: with `--repeat-each=8`, 1–2 of 16 time out.
* So it is a problem with the existing smoke-test server, not with these
stories. I left it untouched here.
### Screenshots (if appropriate)
See the `Assistant/Message` and `Assistant/Status` stories.
### Questions:
* Does the license files need to update? No. There are no new dependencies.
* Is there breaking changes for older versions? No. Only new files are
added, plus the removal of a temporary story.
* Does this needs documentation? No.
Closes #5567 from kimyenac/ZEPPELIN-6745.
Signed-off-by: YONGJAE LEE <[email protected]>
---
.../entities/assistant/ui/AssistantAnswer.css.ts | 91 +++++++++++
.../entities/assistant/ui/AssistantAnswer.spec.tsx | 180 +++++++++++++++++++++
.../assistant/ui/AssistantAnswer.stories.tsx | 95 +++++++++++
.../src/entities/assistant/ui/AssistantAnswer.tsx | 84 ++++++++++
.../assistant/ui/AssistantAnswerError.spec.tsx | 49 ++++++
.../entities/assistant/ui/AssistantAnswerError.tsx | 46 ++++++
.../assistant/ui/AssistantMessageItem.css.ts | 93 +++++++++++
.../assistant/ui/AssistantMessageItem.spec.tsx | 42 +++++
.../assistant/ui/AssistantMessageItem.stories.tsx | 66 ++++++++
.../entities/assistant/ui/AssistantMessageItem.tsx | 67 ++++++++
.../src/entities/assistant/ui/AssistantToolLog.tsx | 53 ++++++
.../src/entities/assistant/ui/answerView.ts | 44 +++++
.../ui/assistant-theme/AssistantScope.stories.tsx | 35 ----
13 files changed, 910 insertions(+), 35 deletions(-)
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.css.ts
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.css.ts
new file mode 100644
index 0000000000..044307eb43
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.css.ts
@@ -0,0 +1,91 @@
+/*
+ * 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 { globalStyle, keyframes, style } from '@vanilla-extract/css';
+import { vars } from '@/shared/ui/assistant-theme/AssistantScope.css';
+
+export const progress = style({
+ display: 'flex',
+ alignItems: 'center',
+ gap: vars.space2,
+ color: vars.textSecondary,
+ fontSize: vars.fontSizeSm
+});
+
+const pulse = keyframes({
+ '0%, 80%, 100%': { opacity: 0.25 },
+ '40%': { opacity: 1 }
+});
+
+export const dots = style({
+ display: 'inline-flex',
+ gap: 3
+});
+
+globalStyle(`${dots} > i`, {
+ width: 4,
+ height: 4,
+ borderRadius: '50%',
+ background: vars.accent,
+ animation: `${pulse} 1.2s ${vars.easeStandard} infinite`
+});
+globalStyle(`${dots} > i:nth-child(2)`, { animationDelay: '0.15s' });
+globalStyle(`${dots} > i:nth-child(3)`, { animationDelay: '0.3s' });
+
+export const skeleton = style({
+ marginTop: vars.space1
+});
+
+export const toolLog = style({
+ border: `1px solid ${vars.border}`,
+ borderRadius: vars.radius,
+ background: vars.fill,
+ fontSize: vars.fontSizeSm
+});
+
+export const toolSummary = style({
+ padding: `${vars.space1} ${vars.space2}`,
+ color: vars.textSecondary,
+ cursor: 'pointer'
+});
+
+export const toolList = style({
+ listStyle: 'none',
+ margin: 0,
+ padding: `0 ${vars.space2} ${vars.space2}`,
+ display: 'flex',
+ flexDirection: 'column',
+ gap: vars.space1
+});
+
+export const toolItem = style({
+ display: 'flex',
+ alignItems: 'center',
+ gap: vars.space2,
+ color: vars.text
+});
+
+export const toolIcon = style({
+ display: 'inline-flex',
+ width: 14,
+ justifyContent: 'center',
+ color: vars.textSecondary
+});
+
+export const toolDone = style({
+ color: vars.success
+});
+
+export const toolName = style({
+ fontFamily: vars.fontMono,
+ overflowWrap: 'anywhere'
+});
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
new file mode 100644
index 0000000000..50edf03585
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx
@@ -0,0 +1,180 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { render, screen, within } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import type { AssistantAnswerView } from './answerView';
+import { AssistantAnswer } from './AssistantAnswer';
+
+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(<AssistantAnswer view={{ status: 'loading' }}
/>);
+ expect(screen.getByRole('status').textContent).toBe('Thinking…');
+ expect(answer().getAttribute('aria-busy')).toBe('true');
+
+ rerender(
+ <AssistantAnswer
+ view={{
+ status: 'streaming',
+ content: 'Revenue is grouped',
+ tools: [{ id: 't1', name: 'list_paragraphs', status: 'done' }]
+ }}
+ />
+ );
+ expect(screen.getByRole('status').textContent).toBe('Writing answer…');
+ expect(answer().textContent).toContain('Revenue is grouped');
+
+ rerender(
+ <AssistantAnswer
+ view={{
+ status: 'done',
+ message: {
+ id: 'm1',
+ role: 'assistant',
+ segments: [
+ { id: 's1', content: '', toolCalls: [{ id: 't1', name:
'list_paragraphs' }] },
+ { id: 's2', content: 'Revenue is grouped by region.' }
+ ]
+ }
+ }}
+ />
+ );
+ expect(screen.queryByRole('status')).toBeNull();
+ expect(screen.queryByRole('alert')).toBeNull();
+ expect(answer().hasAttribute('aria-busy')).toBe(false);
+ expect(answer().textContent).toContain('Revenue is grouped by region.');
+ expect(screen.getByText('Used 1 tool')).toBeTruthy();
+ });
+
+ it('names the running tool in the progress line and marks each tool with its
state', () => {
+ render(
+ <AssistantAnswer
+ view={{
+ status: 'streaming',
+ content: '',
+ tools: [
+ { id: 't1', name: 'list_paragraphs', status: 'done' },
+ { id: 't2', name: 'read_results', status: 'running' }
+ ]
+ }}
+ />
+ );
+ expect(screen.getByRole('status').textContent).toBe('Running
read_results…');
+ expect(screen.getByText('Using 2 tools')).toBeTruthy();
+ const [first, second] = screen.getAllByRole('listitem');
+ expect(within(first).getByRole('img', { name: 'Done' })).toBeTruthy();
+ expect(within(second).getByRole('img', { name: 'Running' })).toBeTruthy();
+ });
+
+ it('opens the tool log while a tool runs and collapses it when the answer
completes', () => {
+ const running: AssistantAnswerView = {
+ status: 'streaming',
+ content: '',
+ tools: [{ id: 't1', name: 'list_paragraphs', status: 'running' }]
+ };
+ const toolLog = () => screen.getByText(/tool$/).closest('details')!;
+ const { rerender } = render(<AssistantAnswer view={running} />);
+ expect(toolLog().open).toBe(true);
+
+ // A user who collapses the log keeps it collapsed while more of the
answer arrives.
+ toolLog().open = false;
+ rerender(<AssistantAnswer view={{ ...running, content: 'Revenue' }} />);
+ expect(toolLog().open).toBe(false);
+
+ rerender(
+ <AssistantAnswer
+ view={{
+ status: 'done',
+ message: {
+ id: 'm1',
+ role: 'assistant',
+ segments: [{ id: 's1', content: 'Revenue', toolCalls: [{ id: 't1',
name: 'list_paragraphs' }] }]
+ }
+ }}
+ />
+ );
+ expect(toolLog().open).toBe(false);
+ });
+
+ it('labels a tool whose completion event carried no name', () => {
+ render(<AssistantAnswer view={{ status: 'streaming', content: '', tools:
[{ id: 't1', status: 'running' }] }} />);
+ expect(screen.getByRole('status').textContent).toBe('Running Tool…');
+ });
+
+ it('keeps completed answer rounds in order and shows tools from every
round', () => {
+ render(
+ <AssistantAnswer
+ view={{
+ status: 'done',
+ message: {
+ id: 'm2',
+ role: 'assistant',
+ segments: [
+ { id: 'm1', content: 'First finding', toolCalls: [{ id: 't1',
name: 'list_paragraphs' }] },
+ { id: 'm2', content: 'Second finding', toolCalls: [{ id: 't2',
name: 'read_results' }] }
+ ]
+ }
+ }}
+ />
+ );
+
+ expect(Array.from(answer().querySelectorAll('p'), p =>
p.textContent)).toEqual(['First finding', 'Second finding']);
+ expect(screen.getByText('Used 2 tools')).toBeTruthy();
+ expect(screen.getAllByRole('listitem').map(item =>
item.textContent)).toEqual(['list_paragraphs', 'read_results']);
+ });
+
+ it('keeps the partial answer and replaces the progress line with the error',
() => {
+ const view: AssistantAnswerView = {
+ status: 'error',
+ kind: 'stream',
+ message: 'The assistant stopped responding before the reply finished.
Retry the request.',
+ content: 'Revenue is grouped'
+ };
+ render(<AssistantAnswer view={view} />);
+ expect(screen.queryByRole('status')).toBeNull();
+ expect(answer().hasAttribute('aria-busy')).toBe(false);
+ expect(answer().textContent).toContain('Revenue is grouped');
+ expect(screen.getByRole('alert').textContent).toContain('stopped
responding');
+ });
+
+ it('does not imply a tool is still observed after the connection is lost',
() => {
+ render(
+ <AssistantAnswer
+ view={{
+ status: 'error',
+ kind: 'connection',
+ message: 'Connection lost',
+ tools: [{ id: 't1', name: 'read_results', status: 'running' }]
+ }}
+ />
+ );
+
+ expect(screen.getByText('Used 1 tool (status unknown)')).toBeTruthy();
+ expect(screen.queryByRole('img', { name: 'Running' })).toBeNull();
+ expect(screen.getByRole('img', { name: 'Status unknown' })).toBeTruthy();
+ });
+
+ it('renders answer bodies through the given renderer', () => {
+ render(
+ <AssistantAnswer
+ view={{
+ status: 'done',
+ message: { id: 'm1', role: 'assistant', segments: [{ id: 's1',
content: '**bold**' }] }
+ }}
+ renderContent={content => <strong>{content.replaceAll('*',
'')}</strong>}
+ />
+ );
+ expect(answer().querySelector('strong')?.textContent).toBe('bold');
+ });
+});
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx
new file mode 100644
index 0000000000..dc472e2696
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx
@@ -0,0 +1,95 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { AssistantConnectionError, AssistantStreamError } from
'../model/assistantContract';
+import type { AssistantAnswerView } from './answerView';
+import { AssistantAnswer } from './AssistantAnswer';
+
+const partial = 'The notebook groups orders by region and';
+const reply = 'The notebook groups orders by region and ranks them by total
revenue.';
+
+/** One fixture per state, in the order a run passes through them. */
+const fixtures = {
+ loading: { status: 'loading' },
+ streaming: { status: 'streaming', content: partial },
+ toolRunning: {
+ status: 'streaming',
+ content: '',
+ tools: [
+ { id: 't1', name: 'list_paragraphs', status: 'done' },
+ { id: 't2', name: 'list_paragraphs', status: 'running' }
+ ]
+ },
+ completed: { status: 'done', message: { id: 'a1', role: 'assistant',
segments: [{ id: 's1', content: reply }] } },
+ completedWithTools: {
+ status: 'done',
+ message: {
+ id: 'a1',
+ role: 'assistant',
+ segments: [
+ {
+ id: 's1',
+ content: '',
+ toolCalls: [
+ { id: 't1', name: 'list_paragraphs' },
+ { id: 't2', name: 'list_paragraphs' }
+ ]
+ },
+ { id: 's2', content: reply }
+ ]
+ }
+ },
+ failed: { status: 'error', kind: 'failed', message: 'The model request
failed.' },
+ streamError: {
+ status: 'error',
+ kind: 'stream',
+ message: new AssistantStreamError().message,
+ content: partial,
+ tools: [{ id: 't1', name: 'list_paragraphs', status: 'done' }]
+ },
+ connectionLost: {
+ status: 'error',
+ kind: 'connection',
+ message: new AssistantConnectionError().message,
+ content: partial,
+ tools: [{ id: 't1', name: 'read_results', status: 'running' }]
+ }
+} satisfies Record<string, AssistantAnswerView>;
+
+type FixtureName = keyof typeof fixtures;
+
+const meta = {
+ title: 'Assistant/Status',
+ component: AssistantAnswer,
+ args: { view: fixtures.loading, onRetry: () => {} }
+} satisfies Meta<typeof AssistantAnswer>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Loading: Story = {};
+export const Streaming: Story = { args: { view: fixtures.streaming } };
+export const ToolRunning: Story = { args: { view: fixtures.toolRunning } };
+export const Completed: Story = { args: { view: fixtures.completed } };
+export const CompletedWithTools: Story = { args: { view:
fixtures.completedWithTools } };
+export const Failed: Story = { args: { view: fixtures.failed } };
+export const StreamError: Story = { args: { view: fixtures.streamError } };
+export const ConnectionLost: Story = { args: { view: fixtures.connectionLost }
};
+export const ErrorWithoutRetry: Story = { args: { view: fixtures.failed,
onRetry: undefined } };
+
+/** Switch the fixture from the controls panel to check that each state fully
replaces the last. */
+export const StateSwitcher: StoryObj<{ state: FixtureName }> = {
+ args: { state: 'loading' },
+ argTypes: { state: { control: 'select', options: Object.keys(fixtures) } },
+ render: ({ state }) => <AssistantAnswer view={fixtures[state]} onRetry={()
=> {}} />
+};
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
new file mode 100644
index 0000000000..1139c73b2e
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx
@@ -0,0 +1,84 @@
+/*
+ * 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 { Skeleton } from 'antd';
+import { answerText, answerToolCalls } from '../model/messageHistory';
+import type { AssistantContentRenderer, AssistantAnswerView,
AssistantToolActivity } from './answerView';
+import { AssistantAnswerError } from './AssistantAnswerError';
+import { AssistantMessageItem } from './AssistantMessageItem';
+import { AssistantToolLog, toolLabel } from './AssistantToolLog';
+import * as styles from './AssistantAnswer.css';
+
+const Progress = ({ text }: { text: string }) => (
+ <div role="status" className={styles.progress}>
+ <span className={styles.dots} aria-hidden="true">
+ <i />
+ <i />
+ <i />
+ </span>
+ {text}
+ </div>
+);
+
+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…';
+};
+
+export interface AssistantAnswerProps {
+ view: AssistantAnswerView;
+ /** Called from the error display. */
+ onRetry?: () => void;
+ 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) => {
+ switch (view.status) {
+ case 'loading':
+ return (
+ <AssistantMessageItem role="assistant" content="" streaming>
+ <Progress text="Thinking…" />
+ <Skeleton className={styles.skeleton} active title={false}
paragraph={{ rows: 2, width: ['92%', '64%'] }} />
+ </AssistantMessageItem>
+ );
+ case 'streaming': {
+ const tools = view.tools ?? [];
+ return (
+ <AssistantMessageItem role="assistant" content={view.content}
streaming renderContent={renderContent}>
+ <AssistantToolLog tools={tools} />
+ <Progress text={progressText(view.content, tools)} />
+ </AssistantMessageItem>
+ );
+ }
+ case 'done': {
+ const tools = answerToolCalls(view.message).map(call => ({ ...call,
status: 'done' as const }));
+ return (
+ <AssistantMessageItem role="assistant"
content={answerText(view.message)} renderContent={renderContent}>
+ <AssistantToolLog tools={tools} />
+ </AssistantMessageItem>
+ );
+ }
+ case 'error': {
+ const tools = (view.tools ?? []).map(tool =>
+ tool.status === 'running' ? { ...tool, status: 'unknown' as const } :
tool
+ );
+ return (
+ <AssistantMessageItem role="assistant" content={view.content ?? ''}
renderContent={renderContent}>
+ <AssistantToolLog tools={tools} />
+ <AssistantAnswerError kind={view.kind} message={view.message}
onRetry={onRetry} />
+ </AssistantMessageItem>
+ );
+ }
+ }
+};
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.spec.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.spec.tsx
new file mode 100644
index 0000000000..644505c632
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.spec.tsx
@@ -0,0 +1,49 @@
+/*
+ * 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 { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { AssistantConnectionError, AssistantStreamError } from
'../model/assistantContract';
+import { AssistantAnswerError } from './AssistantAnswerError';
+
+describe('AssistantAnswerError', () => {
+ it('shows a server failure with a retry action', () => {
+ const onRetry = vi.fn();
+ render(<AssistantAnswerError kind="failed" message="The model request
failed." onRetry={onRetry} />);
+ expect(screen.getByRole('alert').textContent).toContain('The model request
failed.');
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
+ expect(onRetry).toHaveBeenCalledOnce();
+ });
+
+ it('offers retry when the stream ended early', () => {
+ render(<AssistantAnswerError kind="stream" message={new
AssistantStreamError().message} onRetry={() => {}} />);
+ expect(screen.getByRole('alert').textContent).toContain('stopped
responding before the reply finished');
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy();
+ });
+
+ it('asks for a reload, not a retry, when the connection dropped', () => {
+ const onRetry = vi.fn();
+ render(
+ <AssistantAnswerError kind="connection" message={new
AssistantConnectionError().message} onRetry={onRetry} />
+ );
+ expect(screen.getByRole('alert').textContent).toContain('connection to the
server was lost');
+ expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: 'Reload conversation'
}));
+ expect(onRetry).toHaveBeenCalledOnce();
+ });
+
+ it('shows no button when there is nothing to retry', () => {
+ render(<AssistantAnswerError kind="failed" message="The model request
failed." />);
+ expect(screen.getByRole('alert')).toBeTruthy();
+ expect(screen.queryByRole('button')).toBeNull();
+ });
+});
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.tsx
new file mode 100644
index 0000000000..ebfb6e80fa
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.tsx
@@ -0,0 +1,46 @@
+/*
+ * 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 { Alert, Button } from 'antd';
+import type { AssistantAnswerErrorKind } from './answerView';
+
+const presentation: Record<AssistantAnswerErrorKind, { type: 'error' |
'warning'; action: string }> = {
+ failed: { type: 'error', action: 'Retry' },
+ stream: { type: 'error', action: 'Retry' },
+ // The answer may have finished on the server; only reloading the
conversation shows where it got to.
+ connection: { type: 'warning', action: 'Reload conversation' }
+};
+
+export interface AssistantAnswerErrorProps {
+ kind: AssistantAnswerErrorKind;
+ message: string;
+ /** Without it there is nothing to offer, so no button is shown. */
+ onRetry?: () => void;
+}
+
+export const AssistantAnswerError = ({ kind, message, onRetry }:
AssistantAnswerErrorProps) => {
+ const { type, action } = presentation[kind];
+ return (
+ <Alert
+ type={type}
+ showIcon
+ title={message}
+ action={
+ onRetry && (
+ <Button size="small" onClick={onRetry}>
+ {action}
+ </Button>
+ )
+ }
+ />
+ );
+};
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.css.ts
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.css.ts
new file mode 100644
index 0000000000..3ec1583712
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.css.ts
@@ -0,0 +1,93 @@
+/*
+ * 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 { globalStyle, keyframes, style } from '@vanilla-extract/css';
+import { enter, vars } from '@/shared/ui/assistant-theme/AssistantScope.css';
+
+export const message = style({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: vars.space1,
+ minWidth: 0,
+ animation: `${enter} ${vars.durationModerate} ${vars.easeEntrance}`
+});
+
+export const user = style({
+ alignSelf: 'flex-end',
+ alignItems: 'flex-end',
+ maxWidth: '85%'
+});
+
+export const assistant = style({
+ alignSelf: 'stretch'
+});
+
+export const label = style({
+ display: 'flex',
+ alignItems: 'center',
+ gap: vars.space2,
+ color: vars.textSecondary,
+ fontSize: vars.fontSizeSm
+});
+
+export const badge = style({
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: 20,
+ height: 20,
+ borderRadius: vars.radiusSm,
+ background: vars.accent,
+ color: vars.bg,
+ fontSize: vars.fontSizeXs,
+ fontWeight: 600
+});
+
+export const bubble = style({
+ padding: `${vars.space2} ${vars.space3}`,
+ borderRadius: vars.radiusLg,
+ border: `1px solid ${vars.accentBorder}`,
+ background: vars.accentBg,
+ overflowWrap: 'anywhere'
+});
+
+export const body = style({
+ minWidth: 0,
+ overflowWrap: 'anywhere'
+});
+
+export const plainText = style({
+ whiteSpace: 'pre-wrap'
+});
+
+const blink = keyframes({
+ '50%': { opacity: 0 }
+});
+
+/**
+ * Trails the partial answer while it streams. The renderer wraps the answer
in blocks, so the caret goes inside the
+ * last block (or the last item of a closing list) to stay on the line where
the text ends.
+ */
+globalStyle(
+ `${body}[data-streaming] > :last-child > :last-child::after,
${body}[data-streaming] > :last-child > :is(ul, ol):last-child >
li:last-child::after`,
+ {
+ content: '""',
+ display: 'inline-block',
+ width: 2,
+ height: '1em',
+ marginLeft: 2,
+ verticalAlign: 'text-bottom',
+ background: vars.accent,
+ animation: `${blink} 1s steps(1) infinite`
+ }
+);
+globalStyle(`${body}[data-streaming] > :last-child > :is(ul,
ol):last-child::after`, { content: 'none' });
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.spec.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.spec.tsx
new file mode 100644
index 0000000000..d436f22164
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.spec.tsx
@@ -0,0 +1,42 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { AssistantMessageItem } from './AssistantMessageItem';
+
+describe('AssistantMessageItem', () => {
+ it('tells a question from an answer', () => {
+ render(
+ <>
+ <AssistantMessageItem role="user" content="How is revenue calculated?"
/>
+ <AssistantMessageItem role="assistant" content="By region." />
+ </>
+ );
+ expect(screen.getByRole('article', { name: 'Your question'
}).textContent).toBe('YouHow is revenue calculated?');
+ expect(screen.getByRole('article', { name: 'Assistant answer'
}).textContent).toContain('By region.');
+ });
+
+ it('shows a question as typed instead of rendering it', () => {
+ render(<AssistantMessageItem role="user" content="<b>not bold</b>"
renderContent={() => 'rendered'} />);
+ const question = screen.getByRole('article', { name: 'Your question' });
+ expect(question.textContent).toContain('<b>not bold</b>');
+ expect(question.querySelector('b')).toBeNull();
+ });
+
+ it('renders an answer as Markdown by default', () => {
+ render(<AssistantMessageItem role="assistant" content={'Revenue is
**grouped** by `region`.'} />);
+ const answer = screen.getByRole('article', { name: 'Assistant answer' });
+ expect(answer.querySelector('strong')?.textContent).toBe('grouped');
+ expect(answer.querySelector('code')?.textContent).toBe('region');
+ });
+});
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx
new file mode 100644
index 0000000000..69ba1ccff4
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx
@@ -0,0 +1,66 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { AssistantAnswer } from './AssistantAnswer';
+import { AssistantMessageItem } from './AssistantMessageItem';
+
+const question = 'Explain how this notebook calculates revenue.';
+const reply = 'The notebook groups orders by region and ranks them by total
revenue.';
+
+const meta = {
+ title: 'Assistant/Message',
+ component: AssistantMessageItem,
+ args: { role: 'user', content: question }
+} satisfies Meta<typeof AssistantMessageItem>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const User: Story = {};
+
+export const CompletedReply: Story = {
+ args: { role: 'assistant', content: reply }
+};
+
+export const StreamingReply: Story = {
+ args: { role: 'assistant', content: 'The notebook groups orders by region
and', streaming: true }
+};
+
+export const LongContent: Story = {
+ args: {
+ role: 'assistant',
+ content: [
+ 'Paragraph 2 loads `orders` and joins it with `regions` on region_id.',
+ 'Paragraph 3 sums amount per region and sorts the totals in descending
order.',
+ 'A very long identifier such as
orders_by_region_with_currency_normalisation_applied_v2 wraps instead of
overflowing.'
+ ].join('\n\n')
+ }
+};
+
+export const Conversation: Story = {
+ render: () => (
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
+ <AssistantMessageItem role="user" content={question} />
+ <AssistantAnswer
+ view={{
+ status: 'done',
+ message: {
+ id: 'a1',
+ role: 'assistant',
+ segments: [{ id: 's1', content: reply, toolCalls: [{ id: 't1',
name: 'list_paragraphs' }] }]
+ }
+ }}
+ />
+ </div>
+ )
+};
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.tsx
new file mode 100644
index 0000000000..fa6203db25
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.tsx
@@ -0,0 +1,67 @@
+/*
+ * 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 { ReactNode } from 'react';
+import type { AssistantMessage } from '../model/assistantContract';
+import type { AssistantContentRenderer } from './answerView';
+import * as styles from './AssistantMessageItem.css';
+import { MarkdownAnswer } from './MarkdownAnswer';
+
+const renderMarkdown: AssistantContentRenderer = content => <MarkdownAnswer
content={content} />;
+
+export interface AssistantMessageItemProps {
+ role: AssistantMessage['role'];
+ content: string;
+ /** The answer is still arriving: marks it busy and shows a caret after the
text. */
+ streaming?: boolean;
+ renderContent?: AssistantContentRenderer;
+ /** Shown under an assistant answer, such as the tools it used or an error.
*/
+ children?: ReactNode;
+}
+
+export const AssistantMessageItem = ({
+ role,
+ content,
+ streaming = false,
+ renderContent = renderMarkdown,
+ children
+}: AssistantMessageItemProps) => {
+ if (role === 'user') {
+ return (
+ <article className={`${styles.message} ${styles.user}`} data-role="user"
aria-label="Your question">
+ <div className={styles.label}>You</div>
+ <div className={`${styles.bubble} ${styles.plainText}`}>{content}</div>
+ </article>
+ );
+ }
+ return (
+ <article
+ className={`${styles.message} ${styles.assistant}`}
+ data-role="assistant"
+ aria-label="Assistant answer"
+ aria-busy={streaming || undefined}
+ >
+ <header className={styles.label}>
+ <span className={styles.badge} aria-hidden="true">
+ AI
+ </span>
+ <span>Assistant</span>
+ </header>
+ {content && (
+ <div className={styles.body} data-streaming={streaming || undefined}>
+ {renderContent(content)}
+ </div>
+ )}
+ {children}
+ </article>
+ );
+};
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx
new file mode 100644
index 0000000000..aa1359733a
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx
@@ -0,0 +1,53 @@
+/*
+ * 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 { CheckOutlined, QuestionCircleOutlined } from '@ant-design/icons';
+import { Spin } from 'antd';
+import type { AssistantToolActivity } from './answerView';
+import * as styles from './AssistantAnswer.css';
+
+export const toolLabel = (tool: Pick<AssistantToolActivity, 'name'>) =>
tool.name || 'Tool';
+
+export interface AssistantToolLogProps {
+ tools: AssistantToolActivity[];
+}
+
+/** The tools behind an answer. Open while one is still running, collapsed
once they have all finished. */
+export const AssistantToolLog = ({ tools }: AssistantToolLogProps) => {
+ if (!tools.length) return null;
+ const running = tools.some(tool => tool.status === 'running');
+ const unknown = tools.some(tool => tool.status === 'unknown');
+ const count = `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'}`;
+ return (
+ <details className={styles.toolLog} open={running}>
+ <summary className={styles.toolSummary}>
+ {running ? `Using ${count}` : unknown ? `Used ${count} (status
unknown)` : `Used ${count}`}
+ </summary>
+ <ul className={styles.toolList}>
+ {tools.map(tool => (
+ <li key={tool.id} className={styles.toolItem}
data-status={tool.status}>
+ {tool.status === 'running' ? (
+ <span className={styles.toolIcon} role="img"
aria-label="Running">
+ <Spin size="small" />
+ </span>
+ ) : tool.status === 'unknown' ? (
+ <QuestionCircleOutlined className={styles.toolIcon}
aria-label="Status unknown" />
+ ) : (
+ <CheckOutlined className={`${styles.toolIcon}
${styles.toolDone}`} aria-label="Done" />
+ )}
+ <span className={styles.toolName}>{toolLabel(tool)}</span>
+ </li>
+ ))}
+ </ul>
+ </details>
+ );
+};
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/answerView.ts
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/answerView.ts
new file mode 100644
index 0000000000..164a8ed8d5
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/answerView.ts
@@ -0,0 +1,44 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { ReactNode } from 'react';
+import type { AssistantAnswerMessage } from '../model/assistantContract';
+
+/** A tool the assistant called during a live run. `tool_call.done` may omit
the name, so it is optional. */
+export interface AssistantToolActivity {
+ id: string;
+ name?: string;
+ status: 'running' | 'done' | 'unknown';
+}
+
+/**
+ * Why an answer stopped: the server reported `run.failed`, the stream ended
early (`AssistantStreamError`),
+ * or the connection dropped and only a reload shows where the answer got to
(`AssistantConnectionError`).
+ */
+export type AssistantAnswerErrorKind = 'failed' | 'stream' | 'connection';
+
+/** What one answer shows. The panel derives it from run events; the
components only display it. */
+export type AssistantAnswerView =
+ | { status: 'loading' }
+ | { status: 'streaming'; content: string; tools?: AssistantToolActivity[] }
+ | { status: 'done'; message: AssistantAnswerMessage }
+ | {
+ status: 'error';
+ kind: AssistantAnswerErrorKind;
+ message: string;
+ /** The part of the answer that arrived before it stopped. */
+ content?: string;
+ tools?: AssistantToolActivity[];
+ };
+
+/** Renders an answer body. Defaults to `MarkdownAnswer`; a parent can pass
one that resolves paragraph references. */
+export type AssistantContentRenderer = (content: string) => ReactNode;
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/shared/ui/assistant-theme/AssistantScope.stories.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/shared/ui/assistant-theme/AssistantScope.stories.tsx
deleted file mode 100644
index c39bb9d5a1..0000000000
---
a/zeppelin-web-angular/projects/zeppelin-react/src/shared/ui/assistant-theme/AssistantScope.stories.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * 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 { Button, Space, Typography } from 'antd';
-import type { Meta, StoryObj } from '@storybook/react-vite';
-
-const ThemeSurface = () => (
- <Space direction="vertical">
- <Typography.Text>Assistant theme</Typography.Text>
- <Button type="primary">Primary action</Button>
- <Button>Secondary action</Button>
- <Typography.Text type="secondary">Secondary text</Typography.Text>
- </Space>
-);
-
-const meta = {
- title: 'Assistant/Setup',
- component: ThemeSurface,
- parameters: {
- docs: { description: { component: 'Temporary theme check. Remove after the
Assistant UI stories land.' } }
- }
-} satisfies Meta<typeof ThemeSurface>;
-
-export default meta;
-type Story = StoryObj<typeof meta>;
-export const TemporaryThemeCheck: Story = {};