fix(workspace): 统一折叠思考块代码以消除流式抖动

- 移除思考链代码块按长度判断的折叠策略,改为默认折叠\n- 为思考内容与 reasoning-only 路径添加代码块默认折叠渲染\n- 调整流式 Markdown 渲染参数,降低流式重排与闪烁
This commit is contained in:
肖应宇 2026-04-15 13:59:49 +08:00
parent 045b99dd13
commit 7ea2bceb78
3 changed files with 105 additions and 33 deletions

View File

@ -168,18 +168,56 @@ export type ReasoningContentProps = ComponentProps<
};
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
className,
)}
{...props}
>
<Streamdown {...props}>{children}</Streamdown>
</CollapsibleContent>
),
({ className, children, ...props }: ReasoningContentProps) => {
const { isStreaming } = useReasoning();
const thinkingComponents = {
code: ({ className, children, ...codeProps }: ComponentProps<"code">) => {
const isBlock =
typeof className === "string" && className.includes("language-");
if (!isBlock) {
return (
<code className={className} {...codeProps}>
{children}
</code>
);
}
return (
<details className="my-2 rounded-md border">
<summary className="text-muted-foreground cursor-pointer px-3 py-2 text-xs select-none">
Show code
</summary>
<pre className="bg-muted/40 overflow-x-auto border-t p-3 text-xs">
<code className={className} {...codeProps}>
{children}
</code>
</pre>
</details>
);
},
};
return (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
className,
)}
{...props}
>
{isStreaming ? (
<div className="whitespace-pre-wrap break-words">{children}</div>
) : (
<Streamdown
isAnimating={false}
parseIncompleteMarkdown={true}
components={thinkingComponents}
>
{children}
</Streamdown>
)}
</CollapsibleContent>
);
},
);
Reasoning.displayName = "Reasoning";

View File

@ -28,6 +28,7 @@ export type MarkdownContentProps = {
/** Renders markdown content. */
export function MarkdownContent({
content,
isLoading,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
@ -66,6 +67,8 @@ export function MarkdownContent({
return (
<MessageResponse
className={className}
isAnimating={isLoading}
parseIncompleteMarkdown={!isLoading}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}

View File

@ -12,7 +12,7 @@ import {
SquareTerminalIcon,
WrenchIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useMemo, useState, type ComponentProps } from "react";
import type { BundledLanguage } from "shiki";
import {
@ -40,7 +40,6 @@ import { Tooltip } from "../tooltip";
import { MarkdownContent } from "./markdown-content";
const TOOL_CONTENT_COLLAPSE_THRESHOLD = 320;
export function MessageGroup({
className,
@ -83,7 +82,41 @@ export function MessageGroup({
aboveLastToolCallSteps.length + (lastToolCallStep ? 1 : 0);
const shouldShowToolSteps =
!!lastToolCallStep && (showAbove || aboveLastToolCallSteps.length === 0);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
// Disable per-word animation in reasoning/tool chain content to avoid
// repeated DOM churn while streaming updates arrive.
const rehypePlugins = useRehypeSplitWordsIntoSpans(false);
const thinkingComponents = useMemo(
() => ({
code: ({
className,
children,
...props
}: ComponentProps<"code">) => {
const isBlock =
typeof className === "string" && className.includes("language-");
if (!isBlock) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
return (
<details className="my-2 rounded-md border">
<summary className="text-muted-foreground cursor-pointer px-3 py-2 text-xs select-none">
{t.toolCalls.expandContent}
</summary>
<pre className="bg-muted/40 overflow-x-auto border-t p-3 text-xs">
<code className={className} {...props}>
{children}
</code>
</pre>
</details>
);
},
}),
[t.toolCalls.expandContent],
);
return (
<ChainOfThought
className={cn("w-full gap-2 rounded-lg bg-white", className)}
@ -130,6 +163,7 @@ export function MessageGroup({
content={step.reasoning ?? ""}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={thinkingComponents}
/>
}
></ChainOfThoughtStep>
@ -185,6 +219,7 @@ export function MessageGroup({
content={lastReasoningStep.reasoning ?? ""}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={thinkingComponents}
/>
}
></ChainOfThoughtStep>
@ -227,8 +262,8 @@ function ToolCall({
language?: BundledLanguage;
expanded?: boolean;
}) => {
const shouldCollapse = content.length > TOOL_CONTENT_COLLAPSE_THRESHOLD;
const shouldShowCodeBlock = !shouldCollapse || expanded;
// Always start collapsed in thinking blocks; user must explicitly expand.
const shouldShowCodeBlock = expanded;
return (
<div className="space-y-1">
@ -417,28 +452,24 @@ function ToolCall({
return t.toolCalls.executeCommand;
}
const command: string | undefined = (args as { command: string })?.command;
const shouldCollapse =
!!command && command.length > TOOL_CONTENT_COLLAPSE_THRESHOLD;
return (
<ChainOfThoughtStep
key={id}
label={description}
icon={SquareTerminalIcon}
action={
shouldCollapse ? (
<Button
className="h-7 px-3 text-xs"
variant="ghost"
onClick={(event) => {
event.stopPropagation();
setIsCommandExpanded((prev) => !prev);
}}
>
{isCommandExpanded
? t.toolCalls.collapseContent
: t.toolCalls.expandContent}
</Button>
) : undefined
<Button
className="h-7 px-3 text-xs"
variant="ghost"
onClick={(event) => {
event.stopPropagation();
setIsCommandExpanded((prev) => !prev);
}}
>
{isCommandExpanded
? t.toolCalls.collapseContent
: t.toolCalls.expandContent}
</Button>
}
>
{command && (