This is an automated email from the ASF dual-hosted git repository.

voidmatcha pushed a commit to branch 
branch-voidmatcha-assistant-integration-temp
in repository https://gitbox.apache.org/repos/asf/zeppelin.git

commit dbe91174bdbb9a09c3d2430d6d5009f2485f1fad
Author: kimyenac <[email protected]>
AuthorDate: Mon Oct 12 12:53:40 2026 +0900

    [ZEPPELIN-6745] Add AI Assistant answer status, tool log, and error displays
---
 .../entities/assistant/ui/AssistantAnswer.css.ts   |  91 ++++++++++++++
 .../entities/assistant/ui/AssistantAnswer.spec.tsx | 137 +++++++++++++++++++++
 .../src/entities/assistant/ui/AssistantAnswer.tsx  |  79 ++++++++++++
 .../assistant/ui/AssistantAnswerError.spec.tsx     |  49 ++++++++
 .../entities/assistant/ui/AssistantAnswerError.tsx |  46 +++++++
 .../src/entities/assistant/ui/AssistantToolLog.tsx |  48 ++++++++
 6 files changed, 450 insertions(+)

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..7531cf429e
--- /dev/null
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx
@@ -0,0 +1,137 @@
+/*
+ * 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',
+            content: 'Revenue is grouped by region.',
+            toolCalls: [{ id: 't1', name: 'list_paragraphs' }]
+          }
+        }}
+      />
+    );
+    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',
+            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 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('renders answer bodies through the given renderer', () => {
+    render(
+      <AssistantAnswer
+        view={{ status: 'done', message: { id: 'm1', role: 'assistant', 
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.tsx
 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx
new file mode 100644
index 0000000000..b0f6fbecbc
--- /dev/null
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx
@@ -0,0 +1,79 @@
+/*
+ * 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 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 = (view.message.toolCalls ?? []).map(call => ({ ...call, 
status: 'done' as const }));
+      return (
+        <AssistantMessageItem role="assistant" content={view.message.content} 
renderContent={renderContent}>
+          <AssistantToolLog tools={tools} />
+        </AssistantMessageItem>
+      );
+    }
+    case 'error':
+      return (
+        <AssistantMessageItem role="assistant" content={view.content ?? ''} 
renderContent={renderContent}>
+          <AssistantToolLog tools={view.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/AssistantToolLog.tsx
 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx
new file mode 100644
index 0000000000..46a0a111a6
--- /dev/null
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx
@@ -0,0 +1,48 @@
+/*
+ * 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 } 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 count = `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'}`;
+  return (
+    <details className={styles.toolLog} open={running}>
+      <summary className={styles.toolSummary}>{running ? `Using ${count}` : 
`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>
+            ) : (
+              <CheckOutlined className={`${styles.toolIcon} 
${styles.toolDone}`} aria-label="Done" />
+            )}
+            <span className={styles.toolName}>{toolLabel(tool)}</span>
+          </li>
+        ))}
+      </ul>
+    </details>
+  );
+};

Reply via email to