💄 style(workflow): inset partial warning badge (#15773)

* 💄 style(workflow): inset partial warning badge

*  feat(portal): support preview for local markdown images

* 🐛 fix(portal): narrow markdown image src
This commit is contained in:
Arvin Xu
2026-06-13 20:10:08 +08:00
committed by GitHub
parent ebe8411e7e
commit 9cde29fb14
5 changed files with 172 additions and 11 deletions
@@ -363,8 +363,8 @@ const WorkflowCollapse = memo<WorkflowCollapseProps>(
}
case 'partial': {
// Mix of success + failure: show success as the primary state and
// surface a small warning badge at the bottom-right so the overall
// turn still reads as "done" rather than "broken".
// surface a small warning badge slightly inset from the bottom-right
// so the overall turn still reads as "done" rather than "broken".
return (
<div style={{ flex: 'none', position: 'relative' }}>
{wrapInBlock(<Icon color={cssVar.colorSuccess} icon={Check} />)}
@@ -373,12 +373,12 @@ const WorkflowCollapse = memo<WorkflowCollapseProps>(
alignItems: 'center',
background: cssVar.colorBgContainer,
borderRadius: '50%',
bottom: 0,
bottom: 2,
display: 'flex',
height: 10,
justifyContent: 'center',
position: 'absolute',
right: 0,
right: 2,
width: 10,
}}
>
@@ -34,6 +34,7 @@ vi.mock('@lobehub/ui', () => ({
Empty: ({ description }: { description?: ReactNode }) => <div>{description}</div>,
Flexbox: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Icon: () => null,
Image: ({ alt, src }: { alt?: string; src?: string }) => <img alt={alt} src={src} />,
Markdown: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Segmented: () => null,
Text: ({ children }: { children: ReactNode }) => <span>{children}</span>,
+18 -2
View File
@@ -1,6 +1,16 @@
import { isDesktop } from '@lobechat/const';
import type { MarkdownProps } from '@lobehub/ui';
import { ActionIcon, Center, Empty, Flexbox, Icon, Markdown, Segmented, Text } from '@lobehub/ui';
import {
ActionIcon,
Center,
Empty,
Flexbox,
Icon,
Image,
Markdown,
Segmented,
Text,
} from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { CodeIcon, EyeIcon, RefreshCwIcon } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
@@ -44,7 +54,13 @@ const ImagePreview = memo<ImagePreviewProps>(({ blob, filename }) => {
return (
<Center height={'100%'} style={{ overflow: 'auto' }} width={'100%'}>
<img alt={filename} src={imageSrc} style={{ maxWidth: '100%', objectFit: 'contain' }} />
<Image
alt={filename}
objectFit={'contain'}
src={imageSrc}
style={{ maxWidth: '100%' }}
variant={'borderless'}
/>
</Center>
);
});
@@ -0,0 +1,139 @@
import { render, screen } from '@testing-library/react';
import type { CSSProperties } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import MarkdownImage from './MarkdownImage';
const mockImage = vi.hoisted(() => vi.fn());
const mockUseClientDataSWR = vi.hoisted(() => vi.fn());
vi.mock('@lobehub/ui', () => ({
Image: ({
alt,
classNames,
objectFit,
src,
styles,
variant,
}: {
alt?: string;
classNames?: { image?: string };
objectFit?: string;
src?: string;
styles?: { image?: CSSProperties };
variant?: string;
}) => {
mockImage({ alt, classNames, objectFit, src, styles, variant });
return (
<img
alt={alt}
className={classNames?.image}
data-object-fit={objectFit}
data-testid="lobe-image"
data-variant={variant}
src={src}
style={styles?.image}
/>
);
},
}));
vi.mock('antd-style', () => ({
cssVar: {
colorFillQuaternary: 'var(--color-fill-quaternary)',
},
}));
vi.mock('@/libs/swr', () => ({
useClientDataSWR: mockUseClientDataSWR,
}));
vi.mock('@/services/projectFile', () => ({
projectFileService: {
getLocalFilePreview: vi.fn(),
},
}));
describe('MarkdownImage', () => {
beforeEach(() => {
mockImage.mockClear();
mockUseClientDataSWR.mockReset();
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:markdown-image'),
revokeObjectURL: vi.fn(),
});
});
it('renders remote markdown images with the LobeHub Image component', () => {
mockUseClientDataSWR.mockReturnValue({});
render(
<MarkdownImage
alt="remote"
className="markdown-img"
markdownFilePath="/repo/report.md"
src="https://example.com/screenshot.png"
style={{ width: 320 }}
workingDirectory="/repo"
/>,
);
const image = screen.getByTestId('lobe-image');
expect(image).toHaveAttribute('src', 'https://example.com/screenshot.png');
expect(image).toHaveClass('markdown-img');
expect(image).toHaveStyle({ maxWidth: '100%', width: '320px' });
expect(mockImage).toHaveBeenCalledWith(
expect.objectContaining({
objectFit: 'contain',
src: 'https://example.com/screenshot.png',
variant: 'borderless',
}),
);
expect(mockUseClientDataSWR).toHaveBeenCalledWith(null, expect.any(Function), {
revalidateOnFocus: false,
});
});
it('resolves relative markdown images and renders the loaded blob through LobeHub Image', () => {
mockUseClientDataSWR.mockReturnValue({
data: {
blob: new Blob(['image']),
type: 'image',
},
});
render(
<MarkdownImage
alt="local"
deviceId="device-1"
markdownFilePath="/repo/.records/report.md"
src="assets/screenshot.png"
workingDirectory="/repo"
/>,
);
expect(screen.getByTestId('lobe-image')).toHaveAttribute('src', 'blob:markdown-image');
expect(mockUseClientDataSWR).toHaveBeenCalledWith(
['local-markdown-image-preview', 'device-1', '/repo/.records/assets/screenshot.png', '/repo'],
expect.any(Function),
{ revalidateOnFocus: false },
);
});
it('shows a stable placeholder while a relative markdown image is loading', () => {
mockUseClientDataSWR.mockReturnValue({});
render(
<MarkdownImage
alt="local"
markdownFilePath="/repo/report.md"
src="assets/screenshot.png"
workingDirectory="/repo"
/>,
);
expect(screen.queryByTestId('lobe-image')).not.toBeInTheDocument();
expect(screen.getByRole('img', { name: 'local' })).toBeInTheDocument();
});
});
@@ -1,3 +1,4 @@
import { Image } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import type { ComponentProps } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
@@ -15,7 +16,7 @@ interface MarkdownImageProps extends ComponentProps<'img'> {
}
const MarkdownImage = memo<MarkdownImageProps>(
({ alt, deviceId, markdownFilePath, node, src, style, workingDirectory, ...rest }) => {
({ alt, className, deviceId, markdownFilePath, node, src, style, workingDirectory }) => {
void node;
const markdownSrc = typeof src === 'string' ? src : undefined;
@@ -71,12 +72,16 @@ const MarkdownImage = memo<MarkdownImageProps>(
);
}
const resolvedSrc = imageSrc ?? markdownSrc;
return (
<img
<Image
alt={alt}
src={imageSrc ?? src}
style={{ maxWidth: '100%', ...style }}
{...rest}
classNames={className ? { image: className } : undefined}
objectFit={'contain'}
src={resolvedSrc}
styles={{ image: { maxWidth: '100%', ...style } }}
variant={'borderless'}
/>
);
},