Compare commits

...

2 Commits

Author SHA1 Message Date
Innei 571697b251 🐛 fix(conversation): keep workflow errors visible 2026-05-13 16:45:02 +08:00
Innei 4b0e1911a7 🐛 fix(conversation): reserve workflow scrollbar space 2026-05-13 16:04:09 +08:00
4 changed files with 149 additions and 12 deletions
@@ -2,7 +2,7 @@
* @vitest-environment happy-dom
*/
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import ContentBlocksScroll from './ContentBlocksScroll';
@@ -10,11 +10,28 @@ import type { RenderableAssistantContentBlock } from './types';
vi.mock('@lobehub/ui', () => ({
Flexbox: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
ScrollArea: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
ScrollArea: ({
children,
contentProps,
scrollbarProps,
}: {
children?: ReactNode;
contentProps?: { style?: CSSProperties };
scrollbarProps?: { style?: CSSProperties };
}) => (
<div
data-content-style={JSON.stringify(contentProps?.style ?? {})}
data-scrollbar-style={JSON.stringify(scrollbarProps?.style ?? {})}
data-testid="scroll-area"
>
{children}
</div>
),
}));
vi.mock('antd-style', () => ({
createStaticStyles: () => ({
scrollRoot: 'scroll-root',
scrollTask: 'scroll-task',
scrollWorkflow: 'scroll-workflow',
}),
@@ -62,4 +79,26 @@ describe('ContentBlocksScroll', () => {
'true',
);
});
it('reserves content space for the vertical scrollbar', () => {
render(
<ContentBlocksScroll
assistantId="assistant-1"
blocks={[{ content: 'workflow block', id: 'block-2' }]}
variant="workflow"
/>,
);
expect(
JSON.parse(screen.getByTestId('scroll-area').dataset.contentStyle || '{}'),
).toMatchObject({
paddingInlineEnd: 16,
});
expect(
JSON.parse(screen.getByTestId('scroll-area').dataset.scrollbarStyle || '{}'),
).toMatchObject({
marginInlineEnd: 2,
marginInlineStart: 0,
});
});
});
@@ -3,7 +3,7 @@
import type { UIChatMessage } from '@lobechat/types';
import { Flexbox, ScrollArea } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import type { RefObject } from 'react';
import type { CSSProperties, RefObject } from 'react';
import { memo, useMemo } from 'react';
import { resolveAssistantGroupFromMessages } from '../utils/resolveAssistantGroupFromMessages';
@@ -23,6 +23,21 @@ const styles = createStaticStyles(({ css }) => ({
`,
}));
const SCROLL_CONTENT_STYLE = {
color: 'inherit',
display: 'block',
fontSize: 'inherit',
gap: 0,
lineHeight: 'inherit',
paddingInlineEnd: 16,
} satisfies CSSProperties;
const SCROLLBAR_STYLE = {
marginBlock: 8,
marginInlineEnd: 2,
marginInlineStart: 0,
} satisfies CSSProperties;
interface ContentBlocksScrollBaseProps {
disableEditing?: boolean;
onScroll?: () => void;
@@ -92,15 +107,8 @@ const ContentBlocksScroll = memo<ContentBlocksScrollProps>((props) => {
<ScrollArea
scrollFade
className={styles.scrollRoot}
contentProps={{
style: {
color: 'inherit',
display: 'block',
fontSize: 'inherit',
gap: 0,
lineHeight: 'inherit',
},
}}
contentProps={{ style: SCROLL_CONTENT_STYLE }}
scrollbarProps={{ style: SCROLLBAR_STYLE }}
viewportProps={{
className: scrollClass,
ref: scrollRef as RefObject<HTMLDivElement>,
@@ -44,6 +44,7 @@ vi.mock('./WorkflowCollapse', () => ({
contentOverride?: string;
disableMarkdownStreaming?: boolean;
domId?: string;
error?: unknown;
hasToolsOverride?: boolean;
tools?: unknown[];
}>;
@@ -57,6 +58,7 @@ vi.mock('./WorkflowCollapse', () => ({
contentOverride,
disableMarkdownStreaming,
domId,
error,
hasToolsOverride,
tools,
}) => ({
@@ -64,6 +66,7 @@ vi.mock('./WorkflowCollapse', () => ({
contentOverride,
disableMarkdownStreaming: !!disableMarkdownStreaming,
domId,
hasError: !!error,
hasToolsOverride,
toolCount: tools?.length ?? 0,
}),
@@ -79,6 +82,7 @@ vi.mock('./GroupItem', () => ({
contentOverride,
disableMarkdownStreaming,
domId,
error,
hasToolsOverride,
id,
isFirstBlock,
@@ -88,6 +92,7 @@ vi.mock('./GroupItem', () => ({
contentOverride?: string;
disableMarkdownStreaming?: boolean;
domId?: string;
error?: unknown;
hasToolsOverride?: boolean;
id: string;
isFirstBlock?: boolean;
@@ -100,6 +105,7 @@ vi.mock('./GroupItem', () => ({
contentOverride,
disableMarkdownStreaming: !!disableMarkdownStreaming,
domId,
hasError: !!error,
hasToolsOverride,
id,
isFirstBlock: !!isFirstBlock,
@@ -159,6 +165,7 @@ describe('Group', () => {
contentOverride: longContent,
disableMarkdownStreaming: true,
domId: 'block-1__answer',
hasError: false,
hasToolsOverride: false,
id: 'block-1',
isFirstBlock: false,
@@ -169,6 +176,7 @@ describe('Group', () => {
contentOverride: '',
disableMarkdownStreaming: true,
domId: 'block-1__workflow',
hasError: false,
hasToolsOverride: true,
id: 'block-1',
isFirstBlock: false,
@@ -198,6 +206,7 @@ describe('Group', () => {
content: '现在我来搜索资料。',
disableMarkdownStreaming: true,
domId: undefined,
hasError: false,
id: 'block-1',
isFirstBlock: false,
toolCount: 1,
@@ -235,6 +244,7 @@ describe('Group', () => {
contentOverride: '我先帮你查一下。',
disableMarkdownStreaming: true,
domId: 'block-1__answer',
hasError: false,
hasToolsOverride: false,
id: 'block-1',
isFirstBlock: false,
@@ -246,6 +256,7 @@ describe('Group', () => {
contentOverride: '接下来我会继续整理结果。',
disableMarkdownStreaming: true,
domId: 'block-1__workflow',
hasError: false,
hasToolsOverride: true,
toolCount: 1,
},
@@ -253,11 +264,64 @@ describe('Group', () => {
content: '',
disableMarkdownStreaming: false,
domId: undefined,
hasError: false,
toolCount: 1,
},
]);
});
it('keeps assistant runtime errors outside the workflow collapse', () => {
const { container } = render(
<Group
id="assistant-1"
messageIndex={0}
blocks={[
blk({
content: '',
error: {
body: { code: 'rate_limit' },
message: 'rate limit',
type: 'ProviderBizError',
} as any,
id: 'block-1',
tools: [
{ apiName: 'bash', id: 'tool-1' } as any,
{ apiName: 'bash', id: 'tool-2' } as any,
],
}),
]}
/>,
);
const sequence = Array.from(container.querySelectorAll('[data-testid]')).map((node) =>
node.getAttribute('data-testid'),
);
expect(sequence).toEqual(['workflow-segment', 'answer-segment']);
expect(parseWorkflowSegment()).toEqual([
{
content: '',
contentOverride: '',
disableMarkdownStreaming: true,
domId: 'block-1__workflow',
hasError: false,
hasToolsOverride: true,
toolCount: 2,
},
]);
expect(parseAnswerSegment()).toEqual({
content: '',
contentOverride: '',
disableMarkdownStreaming: true,
domId: 'block-1__answer',
hasError: true,
hasToolsOverride: false,
id: 'block-1',
isFirstBlock: false,
toolCount: 0,
});
});
it('renders a single tool call inline instead of folding it', () => {
render(
<Group
@@ -279,6 +343,7 @@ describe('Group', () => {
content: '',
disableMarkdownStreaming: true,
domId: undefined,
hasError: false,
id: 'block-1',
isFirstBlock: false,
toolCount: 1,
@@ -209,6 +209,31 @@ const appendWorkflowRangeBlock = (
block: AssistantContentBlock,
allowLeadingSentencePromotion = false,
) => {
if (block.error) {
if (hasTools(block)) {
appendWorkflowBlock(
segments,
createWorkflowRenderBlock(block, {
content: '',
error: undefined,
imageList: undefined,
reasoning: undefined,
}),
);
appendAnswerBlock(
segments,
createAnswerRenderBlock(block, {
reasoning: undefined,
tools: undefined,
}),
);
return;
}
appendAnswerBlock(segments, block);
return;
}
if (!shouldPromoteMixedBlockContent(block)) {
const leadingSentenceSplit =
allowLeadingSentencePromotion && segments.length === 0 && hasTools(block)