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( export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => ( ({ className, children, ...props }: ReasoningContentProps) => {
<CollapsibleContent const { isStreaming } = useReasoning();
className={cn( const thinkingComponents = {
"mt-4 text-sm", code: ({ className, children, ...codeProps }: ComponentProps<"code">) => {
"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", const isBlock =
className, typeof className === "string" && className.includes("language-");
)} if (!isBlock) {
{...props} return (
> <code className={className} {...codeProps}>
<Streamdown {...props}>{children}</Streamdown> {children}
</CollapsibleContent> </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"; Reasoning.displayName = "Reasoning";

View File

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

View File

@ -12,7 +12,7 @@ import {
SquareTerminalIcon, SquareTerminalIcon,
WrenchIcon, WrenchIcon,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState, type ComponentProps } from "react";
import type { BundledLanguage } from "shiki"; import type { BundledLanguage } from "shiki";
import { import {
@ -40,7 +40,6 @@ import { Tooltip } from "../tooltip";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
const TOOL_CONTENT_COLLAPSE_THRESHOLD = 320;
export function MessageGroup({ export function MessageGroup({
className, className,
@ -83,7 +82,41 @@ export function MessageGroup({
aboveLastToolCallSteps.length + (lastToolCallStep ? 1 : 0); aboveLastToolCallSteps.length + (lastToolCallStep ? 1 : 0);
const shouldShowToolSteps = const shouldShowToolSteps =
!!lastToolCallStep && (showAbove || aboveLastToolCallSteps.length === 0); !!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 ( return (
<ChainOfThought <ChainOfThought
className={cn("w-full gap-2 rounded-lg bg-white", className)} className={cn("w-full gap-2 rounded-lg bg-white", className)}
@ -130,6 +163,7 @@ export function MessageGroup({
content={step.reasoning ?? ""} content={step.reasoning ?? ""}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
components={thinkingComponents}
/> />
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
@ -185,6 +219,7 @@ export function MessageGroup({
content={lastReasoningStep.reasoning ?? ""} content={lastReasoningStep.reasoning ?? ""}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
components={thinkingComponents}
/> />
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
@ -227,8 +262,8 @@ function ToolCall({
language?: BundledLanguage; language?: BundledLanguage;
expanded?: boolean; expanded?: boolean;
}) => { }) => {
const shouldCollapse = content.length > TOOL_CONTENT_COLLAPSE_THRESHOLD; // Always start collapsed in thinking blocks; user must explicitly expand.
const shouldShowCodeBlock = !shouldCollapse || expanded; const shouldShowCodeBlock = expanded;
return ( return (
<div className="space-y-1"> <div className="space-y-1">
@ -417,28 +452,24 @@ function ToolCall({
return t.toolCalls.executeCommand; return t.toolCalls.executeCommand;
} }
const command: string | undefined = (args as { command: string })?.command; const command: string | undefined = (args as { command: string })?.command;
const shouldCollapse =
!!command && command.length > TOOL_CONTENT_COLLAPSE_THRESHOLD;
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
key={id} key={id}
label={description} label={description}
icon={SquareTerminalIcon} icon={SquareTerminalIcon}
action={ action={
shouldCollapse ? ( <Button
<Button className="h-7 px-3 text-xs"
className="h-7 px-3 text-xs" variant="ghost"
variant="ghost" onClick={(event) => {
onClick={(event) => { event.stopPropagation();
event.stopPropagation(); setIsCommandExpanded((prev) => !prev);
setIsCommandExpanded((prev) => !prev); }}
}} >
> {isCommandExpanded
{isCommandExpanded ? t.toolCalls.collapseContent
? t.toolCalls.collapseContent : t.toolCalls.expandContent}
: t.toolCalls.expandContent} </Button>
</Button>
) : undefined
} }
> >
{command && ( {command && (