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 0d344fb7b1 [ZEPPELIN-6751] Prevent automatic image requests in AI 
Assistant answers
0d344fb7b1 is described below

commit 0d344fb7b1318438c98b25fae317543792b962f7
Author: Minho Jang <[email protected]>
AuthorDate: Fri Oct 16 23:29:03 2026 +0900

    [ZEPPELIN-6751] Prevent automatic image requests in AI Assistant answers
    
    ### What is this PR for?
    
    Addresses the image-loading follow-up raised in 
[#5570](https://github.com/apache/zeppelin/pull/5570#issuecomment-6158231443).
    
    Model-generated Markdown can include image URLs containing notebook data. 
Loading these images automatically sends a request to the destination without 
user interaction.
    
    This PR replaces automatic image loading with a **Load image** button. 
Before loading, the reader can review the image description, destination host, 
full URL, and a warning that the request may transmit information from the 
answer. Only HTTP(S) image URLs are supported.
    
    Approval is specific to each image in an answer. Unchanged images retain 
approval during streaming, including when a reference definition gains a title. 
Changing the destination or removing and restoring an image requires fresh 
approval.
    
    ### What type of PR is it?
    
    Bug Fix
    
    ### What is the Jira issue?
    * https://issues.apache.org/jira/browse/ZEPPELIN-6751
    
    ### How should this be tested?
    
    From `zeppelin-web-angular/projects/zeppelin-react`, run:
    
    ```bash
    npm test -- src/entities/assistant/ui/MarkdownAnswer.spec.tsx
    npm run typecheck
    npm run build:storybook
    npm run test:storybook -- markdown-answer-images.spec.mjs
    ```
    
    For manual verification, run `npm run storybook` and open **Assistant / 
MarkdownAnswer**:
    
    1. In **Image Loading**, inspect the browser’s Network panel. Confirm that 
no image request is sent before activation. Use Enter or Space to activate 
**Load image** and confirm that only the selected image loads.
    2. In **Streaming Images**, load the image and append the next chunk. 
Confirm that the image remains loaded while its destination is unchanged. When 
a later chunk changes the destination, confirm that the new URL is displayed 
and requires another activation before loading.
    3. Using the image story’s `content` control, remove an approved image and 
restore the same Markdown. Confirm that it requires approval again.
    
    ### Questions
    
    - **License changes?** No.
    - **Breaking changes?** No.
    - **Documentation?** The React README explains image loading and the 
destination warning.
    
    
    Closes #5572 from miinhho/assistant-image-loading-policy.
    
    Signed-off-by: YONGJAE LEE <[email protected]>
---
 .../projects/zeppelin-react/README.md              |   4 +-
 .../entities/assistant/ui/MarkdownAnswer.css.ts    |  52 ++++
 .../entities/assistant/ui/MarkdownAnswer.spec.tsx  | 269 +++++++++++++++++++++
 .../assistant/ui/MarkdownAnswer.stories.tsx        |  54 +++++
 .../src/entities/assistant/ui/MarkdownAnswer.tsx   |  91 +++----
 .../entities/assistant/ui/MarkdownAnswerImage.tsx  | 186 ++++++++++++++
 .../assistant/ui/remarkParagraphReferences.ts      |  74 ++++++
 .../test/fixtures/markdown-answer-image.svg        |  36 +++
 .../test/storybook/markdown-answer-images.spec.mjs | 139 +++++++++++
 9 files changed, 844 insertions(+), 61 deletions(-)

diff --git a/zeppelin-web-angular/projects/zeppelin-react/README.md 
b/zeppelin-web-angular/projects/zeppelin-react/README.md
index 4c45e4d7ad..1a81621ec7 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/README.md
+++ b/zeppelin-web-angular/projects/zeppelin-react/README.md
@@ -201,4 +201,6 @@ Pass `describeParagraph` and `onOpenParagraph` to enable 
paragraph reference but
 
 HTML is displayed as text and dangerous link URLs are blocked.
 
-See **Assistant / MarkdownAnswer** in Storybook for complete answers, 
streaming, and paragraph reference examples in both themes.
+Markdown images load only after you select **Load image**. Review the 
displayed destination first: loading may transmit information included in the 
URL. Only HTTP(S) URLs are supported.
+
+See **Assistant / MarkdownAnswer** in Storybook for examples in both themes.
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts
 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts
index a39fa0701a..2e54a84c9e 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts
@@ -119,6 +119,58 @@ export const paragraphReference = style({
   }
 });
 
+export const imagePlaceholder = style({
+  display: 'inline-flex',
+  flexDirection: 'column',
+  alignItems: 'flex-start',
+  gap: vars.space2,
+  maxWidth: '100%',
+  margin: `${vars.space1} 0`,
+  padding: vars.space3,
+  border: `1px solid ${vars.border}`,
+  borderRadius: vars.radius,
+  background: vars.fill,
+  verticalAlign: 'middle'
+});
+
+export const imageDescription = style({
+  fontWeight: 600
+});
+
+export const imageDetails = style({
+  display: 'flex',
+  flexDirection: 'column',
+  gap: vars.space1,
+  maxWidth: '100%',
+  color: vars.textSecondary,
+  fontSize: vars.fontSizeSm,
+  overflowWrap: 'anywhere'
+});
+
+export const loadImage = style({
+  padding: `${vars.space1} ${vars.space2}`,
+  border: `1px solid ${vars.accentBorder}`,
+  borderRadius: vars.radiusSm,
+  background: vars.accentBg,
+  color: vars.accentText,
+  font: 'inherit',
+  cursor: 'pointer',
+  selectors: {
+    '&:hover': { color: vars.accentTextHover }
+  }
+});
+
+export const loadedImage = style({
+  display: 'block',
+  maxWidth: '100%',
+  height: 'auto'
+});
+
+export const imageLink = style({
+  display: 'inline',
+  maxWidth: '100%'
+});
+
 export const codeBlock = style({
   minWidth: 0,
   maxWidth: '100%',
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx
 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx
index 629c6ae620..46889eef7d 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx
@@ -16,6 +16,7 @@
  */
 
 import { fireEvent, render, screen, within } from '@testing-library/react';
+import { StrictMode, useLayoutEffect } from 'react';
 import { describe, expect, it, vi } from 'vitest';
 import { visuallyHidden } from 
'@/shared/ui/assistant-theme/AssistantScope.css';
 import { MarkdownAnswer } from './MarkdownAnswer';
@@ -416,6 +417,274 @@ describe('MarkdownAnswer', () => {
     });
   });
 
+  describe('image loading', () => {
+    const sourceA = 
'https://images.example.test/plot.svg?q=SYNTHETIC_NOTEBOOK_VALUE';
+    const sourceB = 
'https://images.example.test/plot.svg?q=UPDATED_NOTEBOOK_VALUE';
+    const imageMarkdown = (source: string) => `![Quarterly revenue](${source} 
"Revenue plot")`;
+
+    it('shows the resolved destination and warning without an image until 
explicitly activated', () => {
+      const { container } = render(<MarkdownAnswer 
content={imageMarkdown(sourceA)} />);
+
+      expect(screen.queryByRole('img')).toBeNull();
+      expect(container.querySelector('img, [src], [srcset], 
link[rel="preload"]')).toBeNull();
+      expect(screen.getByText(`Image URL: ${sourceA}`)).toBeTruthy();
+      expect(screen.getByText('Host: images.example.test')).toBeTruthy();
+      expect(screen.getByText(/loading.*sends a request.*information 
from.*answer/i)).toBeTruthy();
+      expect(container.textContent).toContain('Quarterly revenue');
+      const button = screen.getByRole('button', { name: /load image/i });
+
+      fireEvent.click(button);
+
+      const image = screen.getByRole('img', { name: 'Quarterly revenue' });
+      expect(image.getAttribute('src')).toBe(sourceA);
+      expect(image.getAttribute('alt')).toBe('Quarterly revenue');
+      expect(image.getAttribute('title')).toBe('Revenue plot');
+      expect(image.hasAttribute('srcset')).toBe(false);
+    });
+
+    it('accepts activation before passive image registration has run', () => {
+      const ActivateOnMount = () => {
+        useLayoutEffect(() => {
+          fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+        }, []);
+        return <MarkdownAnswer content={imageMarkdown(sourceA)} />;
+      };
+
+      render(<ActivateOnMount />);
+
+      expect(screen.getByRole('img', { name: 'Quarterly revenue' 
}).getAttribute('src')).toBe(sourceA);
+    });
+
+    it.each(['/assistant-plot.svg?q=relative', 
'//images.example.test/plot.svg?q=protocol-relative'])(
+      'resolves destination %s against the actual document base URI',
+      relativeSource => {
+        const resolvedSource = new URL(relativeSource, document.baseURI);
+        render(<MarkdownAnswer content={imageMarkdown(relativeSource)} />);
+
+        expect(screen.getByText(resolvedSource.href, { exact: false 
})).toBeTruthy();
+        expect(screen.getByText(text => 
text.endsWith(resolvedSource.hostname))).toBeTruthy();
+        expect(screen.queryByRole('img')).toBeNull();
+        fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+        
expect(screen.getByRole('img').getAttribute('src')).toBe(resolvedSource.href);
+      }
+    );
+
+    it('requires fresh consent for query changes, including returning to a 
previously loaded URL', () => {
+      const { rerender } = render(<MarkdownAnswer 
content={imageMarkdown(sourceA)} />);
+      fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+      expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);
+
+      rerender(<MarkdownAnswer content={imageMarkdown(sourceB)} />);
+      expect(screen.queryByRole('img')).toBeNull();
+      fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+      expect(screen.getByRole('img').getAttribute('src')).toBe(sourceB);
+
+      rerender(<MarkdownAnswer content={imageMarkdown(sourceA)} />);
+      expect(screen.queryByRole('img')).toBeNull();
+      expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();
+    });
+
+    it('revokes approval when a different destination is shown without being 
activated', () => {
+      const { rerender } = render(<MarkdownAnswer 
content={imageMarkdown(sourceA)} />);
+      fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+      expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);
+
+      rerender(<MarkdownAnswer content={imageMarkdown(sourceB)} />);
+      expect(screen.queryByRole('img')).toBeNull();
+      expect(screen.getByText(sourceB, { exact: false })).toBeTruthy();
+      expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();
+
+      rerender(<MarkdownAnswer content={imageMarkdown(sourceA)} />);
+      expect(screen.queryByRole('img')).toBeNull();
+      expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();
+    });
+
+    it('requires fresh consent when the destination path changes', () => {
+      const { rerender } = render(<MarkdownAnswer 
content={imageMarkdown(sourceA)} />);
+      fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+
+      rerender(<MarkdownAnswer 
content={imageMarkdown(sourceA.replace('/plot.svg', '/another.svg'))} />);
+
+      expect(screen.queryByRole('img')).toBeNull();
+      expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();
+    });
+
+    it('keeps approval for an unchanged URL while more answer text is 
appended', () => {
+      const content = `Revenue increased.\n\n${imageMarkdown(sourceA)}`;
+      const { rerender } = render(<MarkdownAnswer content={content} />);
+      fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+
+      rerender(<MarkdownAnswer content={`${content}\n\nThe next quarter also 
improved.`} />);
+
+      expect(screen.getByRole('img', { name: 'Quarterly revenue' 
}).getAttribute('src')).toBe(sourceA);
+      expect(screen.queryByRole('button', { name: /load image/i })).toBeNull();
+      expect(screen.getByText('The next quarter also improved.')).toBeTruthy();
+    });
+
+    it('keeps approval and updates the title when a reference definition title 
is appended', () => {
+      const content = `![Existing][plot]\n\n[plot]: ${sourceA}`;
+      const { rerender } = render(<MarkdownAnswer content={content} />);
+      fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+      expect(screen.getByRole('img', { name: 'Existing' 
}).getAttribute('src')).toBe(sourceA);
+
+      rerender(<MarkdownAnswer content={`${content}\n  "Revenue plot"`} />);
+
+      const image = screen.getByRole('img', { name: 'Existing' });
+      expect(image.getAttribute('src')).toBe(sourceA);
+      expect(image.getAttribute('title')).toBe('Revenue plot');
+    });
+
+    it('keeps consent with its source occurrence when an earlier image 
reference resolves', () => {
+      const source = 'https://images.example.test/plot.svg';
+      const initial = `![New][later] ![Existing](${source})`;
+      const { rerender } = render(<MarkdownAnswer content={initial} />);
+      fireEvent.click(screen.getByRole('button', { name: /Existing/ }));
+      expect(screen.getByRole('img', { name: 'Existing' 
}).getAttribute('src')).toBe(source);
+
+      rerender(<MarkdownAnswer content={`${initial}\n\n[later]: ${source}`} 
/>);
+
+      expect(screen.getByRole('button', { name: /New/ })).toBeTruthy();
+      expect(screen.queryByRole('img', { name: 'New' })).toBeNull();
+      expect(screen.getByRole('img', { name: 'Existing' 
}).getAttribute('src')).toBe(source);
+      expect(screen.queryByRole('button', { name: /Existing/ })).toBeNull();
+      expect(screen.getAllByRole('img')).toHaveLength(1);
+    });
+
+    it('keeps existing consent through StrictMode replay when an earlier image 
reference resolves', () => {
+      const initial = `![New][later] ![Existing](${sourceA})`;
+      const { rerender } = render(
+        <StrictMode>
+          <MarkdownAnswer content={initial} />
+        </StrictMode>
+      );
+      fireEvent.click(screen.getByRole('button', { name: /Existing/ }));
+      expect(screen.getByRole('img', { name: 'Existing' 
}).getAttribute('src')).toBe(sourceA);
+
+      rerender(
+        <StrictMode>
+          <MarkdownAnswer content={`${initial}\n\n[later]: ${sourceB}`} />
+        </StrictMode>
+      );
+
+      expect(screen.getByRole('img', { name: 'Existing' 
}).getAttribute('src')).toBe(sourceA);
+      expect(screen.queryByRole('img', { name: 'New' })).toBeNull();
+      fireEvent.click(screen.getByRole('button', { name: /New/ }));
+
+      expect(screen.getByRole('img', { name: 'Existing' 
}).getAttribute('src')).toBe(sourceA);
+      expect(screen.getByRole('img', { name: 'New' 
}).getAttribute('src')).toBe(sourceB);
+    });
+
+    it('does not transfer approval to a replacement image at the same source 
position', () => {
+      const { rerender } = render(<MarkdownAnswer content={`![A](${sourceA})`} 
/>);
+      fireEvent.click(screen.getByRole('button', { name: /Load image: A/ }));
+      expect(screen.getByRole('img', { name: 'A' })).toBeTruthy();
+
+      rerender(<MarkdownAnswer content={`![B](${sourceA})`} />);
+
+      expect(screen.queryByRole('img')).toBeNull();
+      expect(screen.getByRole('button', { name: /Load image: B/ 
})).toBeTruthy();
+    });
+
+    it('does not share approval with another image in the same answer', () => {
+      render(<MarkdownAnswer 
content={`${imageMarkdown(sourceA)}\n\n![Comparison](${sourceA})`} />);
+      const buttons = screen.getAllByRole('button', { name: /load image/i });
+
+      fireEvent.click(buttons[0]);
+
+      expect(screen.getAllByRole('img')).toHaveLength(1);
+      expect(screen.getByRole('img', { name: 'Quarterly revenue' 
})).toBeTruthy();
+      expect(screen.queryByRole('img', { name: 'Comparison' })).toBeNull();
+      expect(screen.getAllByRole('button', { name: /load image/i 
})).toHaveLength(1);
+    });
+
+    it('does not share approval between independently rendered answers', () => 
{
+      const first = render(<MarkdownAnswer content={imageMarkdown(sourceA)} 
/>);
+      fireEvent.click(within(first.container).getByRole('button', { name: 
/load image/i }));
+      const second = render(<MarkdownAnswer content={imageMarkdown(sourceA)} 
/>);
+
+      expect(within(first.container).getByRole('img')).toBeTruthy();
+      expect(within(second.container).queryByRole('img')).toBeNull();
+      expect(within(second.container).getByRole('button', { name: /load 
image/i })).toBeTruthy();
+    });
+
+    it('requires fresh consent when removed image content is restored in the 
same answer', () => {
+      const content = imageMarkdown(sourceA);
+      const { rerender } = render(<MarkdownAnswer content={content} />);
+      fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+      expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);
+
+      rerender(<MarkdownAnswer content="No answer selected." />);
+      expect(screen.queryByRole('img')).toBeNull();
+      expect(screen.queryByRole('button', { name: /load image/i })).toBeNull();
+      expect(screen.getByText('No answer selected.')).toBeTruthy();
+
+      rerender(<MarkdownAnswer content={content} />);
+      expect(screen.queryByRole('img')).toBeNull();
+      fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+      expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);
+    });
+
+    it('requires fresh consent when a previously displayed answer is restored 
after unmounting', () => {
+      const content = imageMarkdown(sourceA);
+      const first = render(<MarkdownAnswer content={content} />);
+      fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+      expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);
+
+      first.unmount();
+      render(<MarkdownAnswer content={content} />);
+
+      expect(screen.queryByRole('img')).toBeNull();
+      expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();
+    });
+
+    it.each(['javascript:alert%281%29', 'ftp://images.example.test/plot.svg', 
''])(
+      'does not offer loading for the unsupported or empty image destination 
%s',
+      source => {
+        const { container } = render(<MarkdownAnswer 
content={imageMarkdown(source)} />);
+
+        expect(screen.queryByRole('img')).toBeNull();
+        expect(screen.queryByRole('button', { name: /load image/i 
})).toBeNull();
+        expect(container.querySelector('[src], [srcset]')).toBeNull();
+        expect(container.textContent).toContain('Quarterly revenue');
+      }
+    );
+
+    it('keeps the original link separate from the image loading button and 
preserves mixed content', () => {
+      const href = 'https://zeppelin.apache.org/docs/latest/';
+      const { container } = render(
+        <MarkdownAnswer content={`[Before **bold** ${imageMarkdown(sourceA)} 
after](${href})`} />
+      );
+      const button = screen.getByRole('button', { name: /load image/i });
+      const links = screen.getAllByRole('link');
+
+      expect(button.closest('a')).toBeNull();
+      expect(button.closest('p')).not.toBeNull();
+      expect(container.querySelector('p p, p div')).toBeNull();
+      expect(container.textContent).toContain('Before');
+      expect(screen.getByText('bold').tagName).toBe('STRONG');
+      expect(container.textContent).toContain('after');
+      for (const link of links) {
+        expect(link.getAttribute('href')).toBe(href);
+        expect(link.getAttribute('target')).toBe('_blank');
+        
expect(link.getAttribute('rel')?.split(/\s+/)).toEqual(expect.arrayContaining(['noopener',
 'noreferrer']));
+      }
+
+      fireEvent.click(button);
+
+      expect(screen.getByRole('img', { name: 'Quarterly revenue' 
}).getAttribute('src')).toBe(sourceA);
+      expect(screen.getAllByRole('link').map(link => 
link.getAttribute('href'))).toEqual(links.map(() => href));
+    });
+
+    it('retains a navigation link for an image-only Markdown link without 
nesting its load button', () => {
+      const href = 'https://zeppelin.apache.org/docs/latest/';
+      render(<MarkdownAnswer content={`[${imageMarkdown(sourceA)}](${href})`} 
/>);
+
+      const button = screen.getByRole('button', { name: /load image/i });
+      expect(button.closest('a')).toBeNull();
+      expect(screen.getByRole('link').getAttribute('href')).toBe(href);
+    });
+  });
+
   it.each(dangerousUrls)('renders the rejected URL %s as non-navigable text', 
url => {
     const { container } = render(<MarkdownAnswer content={`[Unsafe 
link](${url})`} />);
 
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx
 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx
index 58aa97396d..3268ed88ae 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx
@@ -30,6 +30,36 @@ const streamingChunks = [
   'GROUP BY product;\n```\n\nRevenue is grouped by product.'
 ];
 
+const imageFixtureUrl = new 
URL('../../../../test/fixtures/markdown-answer-image.svg?no-inline', 
import.meta.url).href;
+const imageQuerySeparator = imageFixtureUrl.includes('?') ? '&' : '?';
+const streamingImageChunks = [
+  '### Quarterly revenue\n\n![Quarterly revenue 
chart][revenue-chart]\n\nRevenue increased across the quarter.',
+  '\n\nThe strongest growth came from recurring sales.',
+  '\n\nThe updated chart includes the final month of the quarter.'
+];
+
+const StreamingImagesExample = (args: MarkdownAnswerProps) => {
+  const [step, setStep] = useState(0);
+  const completed = step === streamingImageChunks.length - 1;
+  const version = completed ? 'B' : 'A';
+  const content = `${streamingImageChunks.slice(0, step + 
1).join('')}\n\n[revenue-chart]: 
${imageFixtureUrl}${imageQuerySeparator}version=${version}`;
+
+  return (
+    <div>
+      <button type="button" disabled={completed} onClick={() => 
setStep(current => current + 1)}>
+        Append next chunk
+      </button>
+      <button type="button" disabled={step === 0} onClick={() => setStep(0)} 
style={{ marginLeft: 8 }}>
+        Restart stream
+      </button>
+      <p role="status">
+        Chunk {step + 1} of {streamingImageChunks.length}
+      </p>
+      <MarkdownAnswer {...args} content={content} />
+    </div>
+  );
+};
+
 const ParagraphReferenceExample = ({ onOpenParagraph, ...args }: 
MarkdownAnswerProps) => {
   const [selectedParagraph, setSelectedParagraph] = useState('No paragraph 
selected.');
 
@@ -237,3 +267,27 @@ export const Streaming: Story = {
     }
   }
 };
+
+export const ImageLoading: Story = {
+  args: {
+    content: [
+      '### Quarterly revenue',
+      '',
+      'Revenue increased across the quarter, led by recurring sales.',
+      '',
+      `![Quarterly revenue chart](${imageFixtureUrl} "Quarterly revenue")`,
+      '',
+      'The regional breakdown is shown in the chart accompanying the 
documentation:',
+      '',
+      `[Regional comparison: **revenue** ![Regional revenue 
chart](${imageFixtureUrl}${imageQuerySeparator}region=north "Regional 
revenue")](https://zeppelin.apache.org/docs/latest/)`,
+      '',
+      'Northern Europe contributed the largest share.'
+    ].join('\n')
+  }
+};
+
+export const StreamingImages: Story = {
+  render: args => <StreamingImagesExample {...args} />,
+  args: { content: streamingImageChunks[0] },
+  argTypes: { content: { control: false } }
+};
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx
 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx
index b5c69baf09..56f6d33087 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx
@@ -18,11 +18,12 @@
 import { createContext, useContext, useId, useMemo, type 
ComponentPropsWithoutRef, type MouseEvent } from 'react';
 import Markdown, { type Components, type ExtraProps } from 'react-markdown';
 import remarkGfm from 'remark-gfm';
-import type { Link, Parent, Root, Text } from 'mdast';
-import type { Plugin, PluggableList } from 'unified';
+import type { PluggableList } from 'unified';
 
 import { visuallyHidden } from 
'@/shared/ui/assistant-theme/AssistantScope.css';
 import * as styles from './MarkdownAnswer.css';
+import { MarkdownAnswerImage, MarkdownAnswerImageScope } from 
'./MarkdownAnswerImage';
+import { isParagraphId, remarkParagraphReferences } from 
'./remarkParagraphReferences';
 
 export interface MarkdownAnswerProps {
   /** Markdown text of the answer. While it is still arriving, it may end 
inside a code fence. */
@@ -39,74 +40,26 @@ type AnswerContextValue = Pick<MarkdownAnswerProps, 
'describeParagraph' | 'onOpe
 };
 
 const AnswerContext = createContext<AnswerContextValue>({ footnotePrefix: '' 
});
-const paragraphIdPattern = /^paragraph_\d+_-?\d+$/;
-
-const textReferences = (node: Text): Array<Text | Link> | undefined => {
-  // Match a complete ID without splitting Unicode letters, numbers or 
combining marks.
-  const pattern = new 
RegExp('(^|[^\\p{L}\\p{N}\\p{M}_+-])(paragraph_\\d+_-?\\d+)(?![\\p{L}\\p{N}\\p{M}_+-])',
 'gu');
-  let references: Array<Text | Link> | undefined;
-  let offset = 0;
-  let match: RegExpExecArray | null;
-
-  while ((match = pattern.exec(node.value)) !== null) {
-    const id = match[2];
-    const start = match.index + match[1].length;
-    references ??= [];
-    if (start > offset) {
-      references.push({ type: 'text', value: node.value.slice(offset, start) 
});
-    }
-    references.push({ type: 'link', url: `#${id}`, children: [{ type: 'text', 
value: id }] });
-    offset = start + id.length;
-  }
-
-  if (references && offset < node.value.length) {
-    references.push({ type: 'text', value: node.value.slice(offset) });
-  }
-  return references;
-};
-
-const transformReferences = (parent: Parent): void => {
-  let children: Parent['children'] | undefined;
-
-  for (let index = 0; index < parent.children.length; index++) {
-    const child = parent.children[index];
-    let references: Array<Text | Link> | undefined;
-    if (child.type === 'text') {
-      references = textReferences(child);
-    } else if (child.type === 'inlineCode' && 
paragraphIdPattern.test(child.value)) {
-      references = [{ type: 'link', url: `#${child.value}`, children: [child] 
}];
-    } else if (child.type !== 'link' && child.type !== 'linkReference' && 
'children' in child) {
-      transformReferences(child);
-    }
-
-    if (references) {
-      children ??= parent.children.slice(0, index);
-      children.push(...references);
-    } else {
-      children?.push(child);
-    }
-  }
-
-  if (children) parent.children = children;
-};
-
-const remarkParagraphReferences: Plugin<[], Root> = () => transformReferences;
 const remarkPlugins: PluggableList = [remarkGfm, remarkParagraphReferences];
 
 // remark-rehype gives the footnotes heading this fixed id and points every 
footnote reference at it.
 const footnoteLabelId = 'footnote-label';
 
-const AnswerLink = ({ node: _node, href, children, title, ...props }: 
ComponentPropsWithoutRef<'a'> & ExtraProps) => {
+const containsImage = (node: NonNullable<ExtraProps['node']>): boolean =>
+  node.tagName === 'img' || node.children.some(child => child.type === 
'element' && containsImage(child));
+
+const AnswerLink = ({ node, href, children, title, ...props }: 
ComponentPropsWithoutRef<'a'> & ExtraProps) => {
   const { describeParagraph, onOpenParagraph, footnotePrefix } = 
useContext(AnswerContext);
 
   if (!href) return <>{children}</>;
+  const imageLink = node ? containsImage(node) : false;
 
   const fragment = href.startsWith('#');
   const paragraphId = fragment ? href.slice(1) : undefined;
-  if (paragraphId && paragraphIdPattern.test(paragraphId)) {
+  if (paragraphId && isParagraphId(paragraphId)) {
     const description = describeParagraph?.(paragraphId);
     if (description && onOpenParagraph) {
-      return (
+      const reference = (
         <button
           type="button"
           className={styles.paragraphReference}
@@ -116,6 +69,13 @@ const AnswerLink = ({ node: _node, href, children, title, 
...props }: ComponentP
           {description.text}
         </button>
       );
+      return imageLink ? (
+        <span className={styles.imageLink}>
+          {children} {reference}
+        </span>
+      ) : (
+        reference
+      );
     }
     return <>{children}</>;
   }
@@ -132,7 +92,7 @@ const AnswerLink = ({ node: _node, href, children, title, 
...props }: ComponentP
   };
 
   // react-markdown has already applied its default URL safety transform.
-  return (
+  const link = (
     <a
       {...props}
       aria-describedby={
@@ -146,9 +106,17 @@ const AnswerLink = ({ node: _node, href, children, title, 
...props }: ComponentP
       target={fragment ? undefined : '_blank'}
       rel={fragment ? undefined : 'noopener noreferrer'}
     >
-      {children}
+      {imageLink ? 'Open linked page' : children}
     </a>
   );
+  // Keep every original child outside the anchor so an image's native load 
button is never nested in a link.
+  return imageLink ? (
+    <span className={styles.imageLink}>
+      {children} {link}
+    </span>
+  ) : (
+    link
+  );
 };
 
 const AnswerCodeBlock = ({ node, children, ...props }: 
ComponentPropsWithoutRef<'pre'> & ExtraProps) => {
@@ -185,6 +153,7 @@ const AnswerHeading = ({ node: _node, id, ...props }: 
ComponentPropsWithoutRef<'
 const components: Components = {
   h2: AnswerHeading,
   a: AnswerLink,
+  img: MarkdownAnswerImage,
   pre: AnswerCodeBlock,
   table: AnswerTable
 };
@@ -213,7 +182,9 @@ export const MarkdownAnswer = ({ content, 
describeParagraph, onOpenParagraph }:
 
   return (
     <AnswerContext.Provider value={{ describeParagraph, onOpenParagraph, 
footnotePrefix }}>
-      <div className={styles.answer}>{markdown}</div>
+      <MarkdownAnswerImageScope>
+        <div className={styles.answer}>{markdown}</div>
+      </MarkdownAnswerImageScope>
     </AnswerContext.Provider>
   );
 };
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswerImage.tsx
 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswerImage.tsx
new file mode 100644
index 0000000000..1c4d4ab65e
--- /dev/null
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswerImage.tsx
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 {
+  createContext,
+  useContext,
+  useId,
+  useEffect,
+  useMemo,
+  useReducer,
+  type Dispatch,
+  type ComponentPropsWithoutRef,
+  type ReactNode
+} from 'react';
+import type { ExtraProps } from 'react-markdown';
+
+import * as styles from './MarkdownAnswer.css';
+
+type ImageApproval = Readonly<{ url: string; approved: boolean; owner: string 
| undefined }>;
+type ImageApprovals = ReadonlyMap<string, ImageApproval>;
+type ImageApprovalAction =
+  | { type: 'mount' | 'approve'; occurrence: string; url: string; owner: 
string }
+  | { type: 'unmount'; occurrence: string; owner: string }
+  | { type: 'prune' };
+
+const imageApprovalsReducer = (state: ImageApprovals, action: 
ImageApprovalAction): ImageApprovals => {
+  if (action.type === 'prune') {
+    let next: Map<string, ImageApproval> | undefined;
+    state.forEach((approval, occurrence) => {
+      if (approval.owner !== undefined) return;
+      next ??= new Map(state);
+      next.delete(occurrence);
+    });
+    return next ?? state;
+  }
+
+  const previous = state.get(action.occurrence);
+  let approval: ImageApproval;
+  switch (action.type) {
+    case 'mount':
+      if (previous?.url === action.url && previous.owner === action.owner) 
return state;
+      approval = {
+        url: action.url,
+        approved: previous?.url === action.url ? previous.approved : false,
+        owner: action.owner
+      };
+      break;
+    case 'approve':
+      if (previous?.url === action.url && previous.owner === action.owner && 
previous.approved) return state;
+      approval = { url: action.url, approved: true, owner: action.owner };
+      break;
+    case 'unmount':
+      if (previous?.owner !== action.owner) return state;
+      approval = { ...previous, owner: undefined };
+      break;
+  }
+  const next = new Map(state);
+  next.set(action.occurrence, approval);
+  return next;
+};
+
+// Committed consent belongs to a Markdown source occurrence, not a renderer's 
tag-ordinal key.
+const ImageApprovalsContext = createContext<
+  { imageApprovals: ImageApprovals; dispatch: Dispatch<ImageApprovalAction> } 
| undefined
+>(undefined);
+
+export const MarkdownAnswerImageScope = ({ children }: { children: ReactNode 
}) => {
+  const [imageApprovals, dispatch] = useReducer(
+    imageApprovalsReducer,
+    undefined,
+    () => new Map<string, ImageApproval>()
+  );
+  let hasUnmountedImage = false;
+  imageApprovals.forEach(approval => {
+    if (approval.owner === undefined) hasUnmountedImage = true;
+  });
+  // Same-batch rekeys reclaim their grant; prune real removals before 
children can read stale consent.
+  if (hasUnmountedImage) dispatch({ type: 'prune' });
+  const context = useMemo(() => ({ imageApprovals, dispatch }), 
[imageApprovals, dispatch]);
+  return <ImageApprovalsContext.Provider 
value={context}>{children}</ImageApprovalsContext.Provider>;
+};
+
+const resolveImageUrl = (src: ComponentPropsWithoutRef<'img'>['src'], base: 
string | undefined): URL | undefined => {
+  if (typeof src !== 'string' || !src.trim()) return undefined;
+  try {
+    const url = base === undefined ? new URL(src) : new URL(src, base);
+    return url.protocol === 'http:' || url.protocol === 'https:' ? url : 
undefined;
+  } catch {
+    return undefined;
+  }
+};
+
+const useImageApproval = (occurrence: string | undefined, href: string, owner: 
string) => {
+  const context = useContext(ImageApprovalsContext);
+  if (context === undefined) throw new Error('MarkdownAnswerImage must be 
rendered within MarkdownAnswerImageScope.');
+  const { imageApprovals, dispatch } = context;
+  const occurrenceKey = occurrence ?? owner;
+  const approval = imageApprovals.get(occurrenceKey);
+
+  useEffect(() => {
+    dispatch({ type: 'mount', occurrence: occurrenceKey, url: href, owner });
+    return () => {
+      dispatch({ type: 'unmount', occurrence: occurrenceKey, owner });
+    };
+  }, [dispatch, occurrenceKey, href, owner]);
+
+  return {
+    approved: approval?.url === href && approval.approved,
+    approve: () => dispatch({ type: 'approve', occurrence: occurrenceKey, url: 
href, owner })
+  };
+};
+
+type LoadableImageProps = {
+  url: URL;
+  alt: string;
+  title: string | undefined;
+  occurrence: string | undefined;
+};
+
+const LoadableImage = ({ url, alt, title, occurrence }: LoadableImageProps) => 
{
+  const detailsId = useId();
+  const { approved, approve } = useImageApproval(occurrence, url.href, 
detailsId);
+
+  return (
+    <span className={styles.imagePlaceholder}>
+      <span className={styles.imageDescription}>Image description: {alt || 'No 
description provided.'}</span>
+      <span id={detailsId} className={styles.imageDetails}>
+        <span>Host: {url.hostname}</span>
+        <span>Image URL: {url.href}</span>
+        <span>
+          Loading this image sends a request to this host. The request may 
include information from the answer.
+        </span>
+      </span>
+      {approved ? (
+        <img src={url.href} alt={alt} title={title} 
className={styles.loadedImage} />
+      ) : (
+        <button
+          type="button"
+          className={styles.loadImage}
+          aria-label={alt ? `Load image: ${alt}` : 'Load image'}
+          aria-describedby={detailsId}
+          onClick={approve}
+        >
+          Load image
+        </button>
+      )}
+    </span>
+  );
+};
+
+export const MarkdownAnswerImage = ({ node, src, alt, title }: 
ComponentPropsWithoutRef<'img'> & ExtraProps) => {
+  const base = typeof document === 'undefined' ? undefined : document.baseURI;
+  const url = useMemo(() => resolveImageUrl(src, base), [src, base]);
+  const start = node?.position?.start.offset;
+  const end = node?.position?.end.offset;
+  // Reference definitions can acquire a title later without changing the 
image occurrence.
+  const occurrence = useMemo(
+    () => (start === undefined || end === undefined ? undefined : 
JSON.stringify([start, end, alt])),
+    [start, end, alt]
+  );
+  if (!url) {
+    return (
+      <span className={styles.imagePlaceholder}>
+        <span className={styles.imageDescription}>Image description: {alt || 
'No description provided.'}</span>
+        <span>Image unavailable: only HTTP(S) image URLs can be loaded.</span>
+      </span>
+    );
+  }
+  return (
+    <LoadableImage key={`${occurrence}:${url.href}`} occurrence={occurrence} 
url={url} alt={alt ?? ''} title={title} />
+  );
+};
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/remarkParagraphReferences.ts
 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/remarkParagraphReferences.ts
new file mode 100644
index 0000000000..65c96c3152
--- /dev/null
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/remarkParagraphReferences.ts
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 { Link, Parent, Root, Text } from 'mdast';
+import type { Plugin } from 'unified';
+
+const paragraphIdPattern = /^paragraph_\d+_-?\d+$/;
+
+export const isParagraphId = (value: string): boolean => 
paragraphIdPattern.test(value);
+
+const textReferences = (node: Text): Array<Text | Link> | undefined => {
+  // Match a complete ID without splitting Unicode letters, numbers or 
combining marks.
+  const pattern = new 
RegExp('(^|[^\\p{L}\\p{N}\\p{M}_+-])(paragraph_\\d+_-?\\d+)(?![\\p{L}\\p{N}\\p{M}_+-])',
 'gu');
+  let references: Array<Text | Link> | undefined;
+  let offset = 0;
+  let match: RegExpExecArray | null;
+
+  while ((match = pattern.exec(node.value)) !== null) {
+    const id = match[2];
+    const start = match.index + match[1].length;
+    references ??= [];
+    if (start > offset) {
+      references.push({ type: 'text', value: node.value.slice(offset, start) 
});
+    }
+    references.push({ type: 'link', url: `#${id}`, children: [{ type: 'text', 
value: id }] });
+    offset = start + id.length;
+  }
+
+  if (references && offset < node.value.length) {
+    references.push({ type: 'text', value: node.value.slice(offset) });
+  }
+  return references;
+};
+
+const transformReferences = (parent: Parent): void => {
+  let children: Parent['children'] | undefined;
+
+  for (let index = 0; index < parent.children.length; index++) {
+    const child = parent.children[index];
+    let references: Array<Text | Link> | undefined;
+    if (child.type === 'text') {
+      references = textReferences(child);
+    } else if (child.type === 'inlineCode' && isParagraphId(child.value)) {
+      references = [{ type: 'link', url: `#${child.value}`, children: [child] 
}];
+    } else if (child.type !== 'link' && child.type !== 'linkReference' && 
'children' in child) {
+      transformReferences(child);
+    }
+
+    if (references) {
+      children ??= parent.children.slice(0, index);
+      children.push(...references);
+    } else {
+      children?.push(child);
+    }
+  }
+
+  if (children) parent.children = children;
+};
+
+export const remarkParagraphReferences: Plugin<[], Root> = () => 
transformReferences;
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/test/fixtures/markdown-answer-image.svg
 
b/zeppelin-web-angular/projects/zeppelin-react/test/fixtures/markdown-answer-image.svg
new file mode 100644
index 0000000000..27fa8004ee
--- /dev/null
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/test/fixtures/markdown-answer-image.svg
@@ -0,0 +1,36 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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.
+-->
+<svg xmlns="http://www.w3.org/2000/svg"; width="480" height="240" viewBox="0 0 
480 240" role="img" aria-labelledby="title description">
+  <title id="title">Quarterly revenue</title>
+  <desc id="description">Revenue grows from 120 in January to 180 in February 
and 240 in March.</desc>
+  <rect width="480" height="240" rx="8" fill="#f4f7fb" />
+  <g font-family="sans-serif" fill="#233449">
+    <text x="32" y="32" font-size="20">Quarterly revenue</text>
+    <path d="M48 52V194H448" fill="none" stroke="#71839a" stroke-width="2" />
+    <rect x="88" y="124" width="64" height="70" fill="#3071a9" />
+    <rect x="208" y="89" width="64" height="105" fill="#3071a9" />
+    <rect x="328" y="54" width="64" height="140" fill="#3071a9" />
+    <g font-size="16" text-anchor="middle">
+      <text x="120" y="116">120</text>
+      <text x="240" y="81">180</text>
+      <text x="360" y="46">240</text>
+      <text x="120" y="220">January</text>
+      <text x="240" y="220">February</text>
+      <text x="360" y="220">March</text>
+    </g>
+  </g>
+</svg>
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/test/storybook/markdown-answer-images.spec.mjs
 
b/zeppelin-web-angular/projects/zeppelin-react/test/storybook/markdown-answer-images.spec.mjs
new file mode 100644
index 0000000000..ba258f7d8c
--- /dev/null
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/test/storybook/markdown-answer-images.spec.mjs
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 { readFileSync } from 'node:fs';
+import { test, expect } from '@playwright/test';
+
+const fixture = readFileSync(new URL('../fixtures/markdown-answer-image.svg', 
import.meta.url));
+const source = 
'https://images.example.test/plot.svg?q=SYNTHETIC_NOTEBOOK_VALUE';
+const storyId = 'assistant-markdownanswer--image-loading';
+const includesText = value => new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, 
'\\$&'));
+
+async function interceptImages(page, baseURL) {
+  const requests = [];
+  const origin = new URL(baseURL).origin;
+  const isFixture = url => url.pathname.includes('markdown-answer-image') && 
url.pathname.endsWith('.svg');
+  const isObserved = url => /^https?:$/.test(url.protocol) && (url.origin !== 
origin || isFixture(url));
+  page.on('request', request => {
+    if (isObserved(new URL(request.url()))) requests.push(request.url());
+  });
+  await page.route('**/*', async route => {
+    const url = new URL(route.request().url());
+    if (url.hostname === 'images.example.test' || isFixture(url)) {
+      await route.fulfill({ status: 200, contentType: 'image/svg+xml', body: 
fixture });
+    } else if (url.origin !== origin) {
+      // Unexpected external requests fail the assertions without contacting a 
real server.
+      await route.abort();
+    } else {
+      await route.continue();
+    }
+  });
+  return requests;
+}
+
+async function openImages(page) {
+  await page.goto(`/iframe.html?id=${storyId}&viewMode=story`);
+  const root = page.locator('#storybook-root');
+  await expect(root.getByRole('button', { name: /load image/i 
}).first()).toBeVisible();
+  return root;
+}
+
+async function setContent(page, content) {
+  await page.evaluate(
+    ({ id, content }) => {
+      window.__STORYBOOK_PREVIEW__.channel.emit('updateStoryArgs', { storyId: 
id, updatedArgs: { content } });
+    },
+    { id: storyId, content }
+  );
+}
+
+async function expectLoadedImage(image, url) {
+  await expect(image).toHaveAttribute('src', url);
+  await expect.poll(() => image.evaluate(element => element.complete && 
element.naturalWidth > 0)).toBe(true);
+}
+
+test('only keyboard-confirmed destinations are requested, including after a 
query change', async ({
+  page,
+  baseURL
+}) => {
+  const requests = await interceptImages(page, baseURL);
+  const root = await openImages(page);
+  await setContent(page, `Revenue improved.\n\n![Quarterly revenue 
chart](${source})`);
+  const load = root.getByRole('button', { name: /load image/i });
+  await expect(load).toHaveAccessibleDescription(includesText(source));
+  await expect(root.locator('img, [srcset], 
link[rel="preload"]')).toHaveCount(0);
+  await page.waitForLoadState('networkidle');
+  expect(requests).toEqual([]);
+
+  await load.focus();
+  await load.press('Enter');
+  await expectLoadedImage(root.getByRole('img', { name: 'Quarterly revenue 
chart' }), source);
+  expect(requests).toEqual([source]);
+
+  const revised = source.replace('SYNTHETIC_NOTEBOOK_VALUE', 
'UPDATED_NOTEBOOK_VALUE');
+  await setContent(page, `The revised quarter includes final 
sales.\n\n![Quarterly revenue chart](${revised})`);
+  await expect(load).toHaveAccessibleDescription(includesText(revised));
+  await expect(root.getByRole('img')).toHaveCount(0);
+  await page.waitForLoadState('networkidle');
+  expect(requests).toEqual([source]);
+  await load.focus();
+  await load.press('Space');
+  await expectLoadedImage(root.getByRole('img', { name: 'Quarterly revenue 
chart' }), revised);
+  expect(requests).toEqual([source, revised]);
+});
+
+test('streaming reference images preserve approval on append and require 
approval for a changed destination', async ({
+  page,
+  baseURL
+}) => {
+  const requests = await interceptImages(page, baseURL);
+  await 
page.goto('/iframe.html?id=assistant-markdownanswer--streaming-images&viewMode=story');
+  const root = page.locator('#storybook-root');
+  const load = root.getByRole('button', { name: /load image/i });
+  await expect(load).toBeVisible();
+  const descriptionId = await load.getAttribute('aria-describedby');
+  const disclosed = await 
page.locator(`[id="${descriptionId}"]`).textContent();
+  await expect(root.getByRole('img')).toHaveCount(0);
+  await page.waitForLoadState('networkidle');
+  expect(requests).toEqual([]);
+  await load.press('Enter');
+  const image = root.getByRole('img', { name: 'Quarterly revenue chart' });
+  await expect.poll(() => image.evaluate(element => element.complete && 
element.naturalWidth > 0)).toBe(true);
+  const sourceA = await image.getAttribute('src');
+  expect(disclosed).toContain(sourceA);
+  expect(requests).toEqual([sourceA]);
+
+  const initialProgress = await root.getByRole('status').textContent();
+  await root.getByRole('button', { name: 'Append next chunk', exact: true 
}).click();
+  await expect(root.getByRole('status')).not.toHaveText(initialProgress);
+  await expectLoadedImage(image, sourceA);
+  await expect(load).toHaveCount(0);
+  await page.waitForLoadState('networkidle');
+  expect(requests).toEqual([sourceA]);
+
+  const revised = new URL(sourceA);
+  revised.searchParams.set('version', 'B');
+  const sourceB = revised.href;
+  await root.getByRole('button', { name: 'Append next chunk', exact: true 
}).click();
+  await expect(load).toHaveAccessibleDescription(includesText(sourceB));
+  await expect(image).toHaveCount(0);
+  await page.waitForLoadState('networkidle');
+  expect(requests).toEqual([sourceA]);
+  await load.press('Space');
+  await expectLoadedImage(image, sourceB);
+  expect(requests).toEqual([sourceA, sourceB]);
+});

Reply via email to