Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/web-ui/src/flow_chat/components/ChatInputAttachment.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@
&:focus-within .openbitfun-chat-input__attachment-remove { opacity: 1; }
}

&__image-chip-preview {
display: block;
inline-size: 100%;
block-size: 100%;
padding: 0;
border: 0;
border-radius: inherit;
background: none;
cursor: zoom-in;

&:focus-visible {
outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring);
outline-offset: var(--openbitfun-focus-offset);
}
}

&__image-chip-thumb {
width: 100%;
height: 100%;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ vi.mock('@/shared/notification-system', () => ({ notificationService: { warning:
vi.mock('../store/FlowChatStore', () => ({ flowChatStore: {
getState: () => ({ sessions: new Map([['main', { sessionId: 'main', dialogTurns: [] }]]) }),
} }));
vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({
formatNumber: (number: number) => String(number),
t: (key: string, values?: Record<string, string>) => key === 'selection.numbered' ? `Annotation ${values?.number}`
: key === 'selection.removeNumbered' ? `Remove ${values?.annotation}` : key,
}) }));
vi.mock('@/infrastructure/i18n', () => ({
useI18n: () => ({
formatNumber: (number: number) => String(number),
t: (key: string, values?: Record<string, string>) => key === 'selection.numbered' ? `Annotation ${values?.number}`
: key === 'selection.removeNumbered' ? `Remove ${values?.annotation}` : key,
}),
i18nService: { t: (key: string) => key },
}));

const excerpt: ConversationExcerptContext = {
id: 'annotation-1', type: 'conversation-excerpt', timestamp: 1, annotationNumber: 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ vi.mock('@/infrastructure/peer-device/deviceSurface', () => ({ getActiveSurfaceS

const { readFileContent } = vi.hoisted(() => ({ readFileContent: vi.fn() }));
vi.mock('@/infrastructure/api/service-api/WorkspaceAPI', () => ({ workspaceAPI: { readFileContent } }));
vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (_key: string, values: { message: string }) => `Load failed: ${values.message}` }) }));
vi.mock('@/infrastructure/i18n', () => ({
useI18n: () => ({ t: (_key: string, values: { message: string }) => `Load failed: ${values.message}` }),
i18nService: { t: (key: string) => key },
}));
vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ warn: vi.fn(), debug: vi.fn(), info: vi.fn(), error: vi.fn() }) }));

const image = { id: 'image', imageName: 'Photo.png', imagePath: '/Lark images/Photo.png', mimeType: 'image/png' } as ImageContext;
Expand Down Expand Up @@ -59,4 +62,21 @@ describe('ChatInputImagePreview', () => {
await act(async () => { finishOld('OLD'); });
expect(container.querySelector('img')!.getAttribute('src')).toBe('data:image/png;base64,NEW');
});

it('previews the resolved thumbnail and closes the overlay on demand', async () => {
await render(<ChatInputImagePreview image={{ ...image, dataUrl: 'data:image/png;base64,AA==' }} surfaceEpoch={1} />);
const trigger = container.querySelector<HTMLButtonElement>('.openbitfun-chat-input__image-chip-preview')!;
expect(trigger.getAttribute('aria-label')).toBe('components:imageLightbox.label');
expect(document.querySelector('.image-lightbox')).toBeNull();

act(() => { trigger.click(); });

const overlay = document.querySelector<HTMLElement>('.image-lightbox');
expect(overlay).not.toBeNull();
expect(overlay!.querySelector('img')!.getAttribute('src')).toBe('data:image/png;base64,AA==');

act(() => { document.querySelector<HTMLButtonElement>('.image-lightbox-close')!.click(); });

expect(document.querySelector('.image-lightbox')).toBeNull();
});
});
26 changes: 22 additions & 4 deletions src/web-ui/src/flow_chat/components/ChatInputImagePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
import { Icon } from '@openbitfun/ui';
import { workspaceAPI } from '@/infrastructure/api/service-api/WorkspaceAPI';
import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface';
import { useI18n } from '@/infrastructure/i18n';
import { i18nService, useI18n } from '@/infrastructure/i18n';
import { ImageLightbox, type ImageLightboxState } from '@/shared/ui/ImageLightbox';
import type { ImageContext } from '@/types/context';
import { getMimeTypeFromFilename } from '../utils/imageUtils';

Expand All @@ -15,6 +16,8 @@ export function ChatInputImagePreview({ image, surfaceEpoch }: {
const path = image.imagePath;
const [loaded, setLoaded] = useState<{ path: string; epoch: number; source: string } | null>(null);
const [error, setError] = useState<string | null>(null);
// The attachment owns the overlay for the bytes it resolved.
const [preview, setPreview] = useState<ImageLightboxState | null>(null);
const source = embedded || (loaded?.path === path && loaded.epoch === surfaceEpoch ? loaded.source : undefined);

useEffect(() => {
Expand All @@ -36,10 +39,25 @@ export function ChatInputImagePreview({ image, surfaceEpoch }: {
return () => { cancelled = true; };
}, [embedded, path, image.mimeType, surfaceEpoch]);

useEffect(() => {
// A surface switch invalidates the bytes behind an open preview.
setPreview(null);
}, [surfaceEpoch]);

return source && !error ? (
<img className="openbitfun-chat-input__image-chip-thumb"
data-openbitfun-component="chat-input" data-openbitfun-part="imagePreview"
src={source} alt={image.imageName} onError={() => setError(image.imageName)} />
<>
<button
type="button"
className="openbitfun-chat-input__image-chip-preview"
aria-label={i18nService.t('components:imageLightbox.label')}
onClick={() => setPreview({ source, alt: image.imageName })}
>
<img className="openbitfun-chat-input__image-chip-thumb"
data-openbitfun-component="chat-input" data-openbitfun-part="imagePreview"
src={source} alt={image.imageName} onError={() => setError(image.imageName)} />
</button>
<ImageLightbox image={preview} onClose={() => setPreview(null)} />
</>
) : (
<div className="openbitfun-chat-input__image-chip-thumb openbitfun-chat-input__image-chip-thumb--placeholder"
data-openbitfun-component="chat-input" data-openbitfun-part="imagePreview"
Expand Down
14 changes: 14 additions & 0 deletions src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.scss
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@
flex-direction: column;
}

.image-content-preview {
display: block;
max-inline-size: 100%;
padding: 0;
border: 0;
background: none;
cursor: zoom-in;

img {
display: block;
max-inline-size: 100%;
}
}

.mcp-copyable-content {
position: relative;
min-width: 0;
Expand Down
51 changes: 50 additions & 1 deletion src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,14 @@ vi.mock('@/infrastructure/api/service-api/MCPAPI', () => ({
},
}));

vi.mock('@/infrastructure/i18n', () => ({
i18nService: { t: (key: string) => key },
}));

vi.mock('@/shared/utils/logger', () => ({
createLogger: () => ({
trace: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
Expand Down Expand Up @@ -112,7 +118,7 @@ describe('MCPToolDisplay', () => {
});
Object.defineProperty(dom.window, 'matchMedia', {
configurable: true,
value: () => ({ matches: true }),
value: () => ({ matches: true, addEventListener: () => {}, removeEventListener: () => {} }),
});
vi.stubGlobal('window', dom.window);
vi.stubGlobal('document', dom.window.document);
Expand Down Expand Up @@ -362,4 +368,47 @@ describe('MCPToolDisplay', () => {
expect(container.querySelector<HTMLButtonElement>('.mcp-input-disclosure button[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false');
expect(container.querySelector('.mcp-input-code')).toBeNull();
});

it('previews a result image on click without collapsing the card', () => {
const item = toolItem({
toolResult: {
success: true,
result: {
content: [{ type: 'image', data: 'aW1n', mime_type: 'image/png' }],
},
},
});

act(() => {
root.render(<MCPToolDisplay toolItem={item} config={config} />);
});

act(() => {
container.querySelector('[data-testid="mcp-tool-card-toggle"]')?.dispatchEvent(
new dom.window.MouseEvent('click', { bubbles: true })
);
});

const trigger = container.querySelector<HTMLButtonElement>('.image-content-preview');
expect(trigger).not.toBeNull();
expect(container.querySelector('.image-content')?.getAttribute('data-openbitfun-part')).toBe('image');

act(() => {
trigger?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});

const overlay = dom.window.document.querySelector<HTMLElement>('.image-lightbox');
expect(overlay).not.toBeNull();
expect(overlay?.getAttribute('data-openbitfun-native-webview-occlusion')).toBe('true');
expect(overlay?.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,aW1n');
expect(container.querySelector('[data-openbitfun-component="mcp-tool-display"]')?.getAttribute('data-openbitfun-state')).toContain('expanded');

act(() => {
dom.window.document.querySelector<HTMLButtonElement>('.image-lightbox-close')?.dispatchEvent(
new dom.window.MouseEvent('click', { bubbles: true })
);
});

expect(dom.window.document.querySelector('.image-lightbox')).toBeNull();
});
});
17 changes: 16 additions & 1 deletion src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { ToolInfo } from '@/shared/types/agent-api';
import { ToolCardCopyAction } from './ToolCardCopyAction';
import { useToolCardHeightContract } from './useToolCardHeightContract';
import { useFlowChatContext } from '../components/modern/FlowChatContext';
import { ImageLightbox, type ImageLightboxState } from '@/shared/ui/ImageLightbox';
import './MCPToolDisplay.scss';

const log = createLogger('MCPToolDisplay');
Expand Down Expand Up @@ -204,6 +205,8 @@ export const MCPToolDisplay: React.FC<ToolCardProps> = ({
} = toolItem;
const [isExpanded, setIsExpanded] = useState(false);
const [isInputExpanded, setIsInputExpanded] = useState(false);
// Tool-result images are inline content of this card, so the card owns their overlay.
const [imagePreview, setImagePreview] = useState<ImageLightboxState | null>(null);
const toolId = toolItem.id ?? toolCall?.id;
const { cardRootRef, applyExpandedState, dispatchToolCardToggle } = useToolCardHeightContract({
toolId,
Expand Down Expand Up @@ -821,7 +824,18 @@ export const MCPToolDisplay: React.FC<ToolCardProps> = ({
)}
{item.type === 'image' && item.data && (
<div className="image-content" data-openbitfun-component="mcp-tool-display" data-openbitfun-part="image">
<img src={`data:${item.mime_type ?? 'image/png'};base64,${item.data}`} alt="" />
<button
type="button"
className="image-content-preview"
aria-label={t('toolCards.common.viewDetails')}
onClick={(event) => {
// The card itself toggles on click; the preview owns this click.
event.stopPropagation();
setImagePreview({ source: `data:${item.mime_type ?? 'image/png'};base64,${item.data}` });
}}
>
<img src={`data:${item.mime_type ?? 'image/png'};base64,${item.data}`} alt="" />
</button>
</div>
)}
{item.type === 'resource' && item.resource && (
Expand Down Expand Up @@ -866,6 +880,7 @@ export const MCPToolDisplay: React.FC<ToolCardProps> = ({
requiresConfirmation={needsConfirmation}
toggleTestId="mcp-tool-card-toggle"
/>
<ImageLightbox image={imagePreview} onClose={() => setImagePreview(null)} />
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { announcementAppearanceDescriptor } from '@/shared/announcement-system/a
import { contextMenuAppearanceDescriptor } from '@/shared/context-menu-system/appearance';
import { contextListAppearanceDescriptor } from '@/shared/context-system/appearance';
import { notificationAppearanceDescriptor } from '@/shared/notification-system/appearance';
import { imageLightboxAppearanceDescriptor } from '@/shared/ui/ImageLightbox.appearance';
import { canvasToolAppearanceDescriptor } from '@/tools/openbitfun-canvas/appearance';
import { generativeWidgetAppearanceDescriptor } from '@/tools/generative-widget/appearance';
import { editorToolAppearanceDescriptor } from '@/tools/editor/appearance';
Expand Down Expand Up @@ -312,6 +313,7 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry {
.registerComponent(contextMenuAppearanceDescriptor)
.registerComponent(contextListAppearanceDescriptor)
.registerComponent(notificationAppearanceDescriptor)
.registerComponent(imageLightboxAppearanceDescriptor)
.registerComponent(canvasToolAppearanceDescriptor)
.registerComponent(generativeWidgetAppearanceDescriptor)
.registerComponent(editorToolAppearanceDescriptor)
Expand Down
10 changes: 10 additions & 0 deletions src/web-ui/src/infrastructure/markdown/Markdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,16 @@
opacity: 0.75;
}

/* Only sources with real bytes reach this state, so the affordance is honest. */
.markdown-renderer .markdown-image--previewable {
cursor: zoom-in;
}

/* A linked image belongs to its link, not to the preview affordance. */
.markdown-renderer :is(a, button):has(> img) > img.markdown-image--previewable {
cursor: pointer;
}

.markdown-renderer .markdown-image-fallback {
display: inline-flex;
align-items: center;
Expand Down
95 changes: 95 additions & 0 deletions src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -901,4 +901,99 @@ describe('Markdown file links', () => {
'remote-connection-1',
);
});

it('previews the resolved bytes of a markdown image and closes on the scrim or the close button', async () => {
await act(async () => {
root.render(
<MarkdownRenderer
content={'![ReLU 图像](relu.png)'}
basePath={EXAMPLE_WORKSPACE}
onFileViewRequest={onFileViewRequest}
/>,
);
await Promise.resolve();
await Promise.resolve();
});

const image = container.querySelector<HTMLImageElement>('img[alt="ReLU 图像"]');
expect(image?.classList.contains('markdown-image--previewable')).toBe(true);

act(() => image?.click());

const overlay = document.querySelector<HTMLElement>('.image-lightbox');
expect(overlay).not.toBeNull();
expect(overlay?.getAttribute('data-openbitfun-native-webview-occlusion')).toBe('true');
// The preview shows the bytes the inline image resolved, not the raw path.
const preview = overlay?.querySelector<HTMLImageElement>('img');
expect(preview?.getAttribute('src')).toBe('data:image/png;base64,cmVsdS1wbmc=');
expect(preview?.getAttribute('data-openbitfun-part')).toBe('image');
const surface = overlay?.querySelector<HTMLElement>('.image-lightbox-surface');
expect(surface?.getAttribute('aria-label')).toBe('ReLU 图像');

// Clicking the previewed image itself must not dismiss the overlay.
act(() => preview?.click());
expect(document.querySelector('.image-lightbox')).not.toBeNull();

act(() => surface?.click());
expect(document.querySelector('.image-lightbox')).toBeNull();

act(() => image?.click());
act(() => document.querySelector<HTMLButtonElement>('.image-lightbox-close')?.click());
expect(document.querySelector('.image-lightbox')).toBeNull();
});

it('leaves images owned by a markdown link to that link', async () => {
await act(async () => {
root.render(
<MarkdownRenderer
content={'[![Badge](data:image/png;base64,YQ==)](README.md)'}
basePath={EXAMPLE_WORKSPACE}
onFileViewRequest={onFileViewRequest}
/>,
);
await Promise.resolve();
});

const image = container.querySelector<HTMLImageElement>('img[alt="Badge"]');
expect(image?.classList.contains('markdown-image--previewable')).toBe(true);

act(() => image?.click());

expect(document.querySelector('.image-lightbox')).toBeNull();
// The file link still owns the click.
expect(onFileViewRequest).toHaveBeenCalled();
});

it('does not offer a preview before an inline image resolves', async () => {
mocks.readFileContent.mockImplementationOnce(() => new Promise<string>(() => {}));
await act(async () => {
root.render(
<MarkdownRenderer
content={'![Pending](pending.png)'}
basePath={EXAMPLE_WORKSPACE}
onFileViewRequest={onFileViewRequest}
/>,
);
await Promise.resolve();
});

const image = container.querySelector<HTMLImageElement>('img[alt="Pending"]');
expect(image?.classList.contains('markdown-image--previewable')).toBe(false);
act(() => image?.click());
expect(document.querySelector('.image-lightbox')).toBeNull();
});

it('closes an open image preview when the surface switches hosts', async () => {
await act(async () => {
root.render(<MarkdownRenderer content={'![Preview](data:image/png;base64,YQ==)'} />);
});

act(() => container.querySelector<HTMLImageElement>('img')?.click());
expect(document.querySelector('.image-lightbox')).not.toBeNull();

await act(async () => activateSurface('peer:output-second'));

// The previewed bytes belonged to the previous host.
expect(document.querySelector('.image-lightbox')).toBeNull();
});
});
Loading
Loading