feat: Improve the display of thinking content in the desktop UI (#8414)
Signed-off-by: jh-block <jhugo@block.xyz>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useMemo, useRef } from 'react';
|
||||
import ImagePreview from './ImagePreview';
|
||||
import { formatMessageTimestamp } from '../utils/timeUtils';
|
||||
import MarkdownContent from './MarkdownContent';
|
||||
import ThinkingContent from './ThinkingContent';
|
||||
import ToolCallWithResponse from './ToolCallWithResponse';
|
||||
import {
|
||||
getTextAndImageContent,
|
||||
@@ -47,27 +48,9 @@ export default function GooseMessage({
|
||||
}: GooseMessageProps) {
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
let { textContent, imagePaths } = getTextAndImageContent(message);
|
||||
const { textContent: displayText, imagePaths } = getTextAndImageContent(message);
|
||||
const thinkingContent = getThinkingContent(message);
|
||||
|
||||
const splitChainOfThought = (text: string): { displayText: string; cotText: string | null } => {
|
||||
const regex = /<think>([\s\S]*?)<\/think>/i;
|
||||
const match = text.match(regex);
|
||||
if (!match) {
|
||||
return { displayText: text, cotText: null };
|
||||
}
|
||||
|
||||
const cotRaw = match[1].trim();
|
||||
const displayText = text.replace(regex, '').trim();
|
||||
|
||||
return {
|
||||
displayText,
|
||||
cotText: cotRaw || null,
|
||||
};
|
||||
};
|
||||
|
||||
const { displayText, cotText } = splitChainOfThought(textContent);
|
||||
|
||||
const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]);
|
||||
const toolRequests = getToolRequests(message);
|
||||
const messageIndex = messages.findIndex((msg) => msg.id === message.id);
|
||||
@@ -132,15 +115,10 @@ export default function GooseMessage({
|
||||
<div className="goose-message flex w-[90%] justify-start min-w-0">
|
||||
<div className="flex flex-col w-full min-w-0">
|
||||
{thinkingContent && (
|
||||
<div className="mb-2 text-xs text-gray-400/70 italic">
|
||||
<MarkdownContent content={thinkingContent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cotText && (
|
||||
<div className="mb-2 text-sm text-gray-400 italic">
|
||||
<MarkdownContent content={cotText} />
|
||||
</div>
|
||||
<ThinkingContent
|
||||
content={thinkingContent}
|
||||
isExpanded={isStreaming && !displayText.trim() && imagePaths.length === 0 && toolRequests.length === 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(displayText.trim() || imagePaths.length > 0) && (
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import MarkdownContent from './MarkdownContent';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import Expand from './ui/Expand';
|
||||
|
||||
interface ThinkingContentProps {
|
||||
content: string;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
export default function ThinkingContent({ content, isExpanded }: ThinkingContentProps) {
|
||||
const [manualToggle, setManualToggle] = useState<boolean | null>(null);
|
||||
const prevIsExpanded = useRef(isExpanded);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevIsExpanded.current && !isExpanded) {
|
||||
setManualToggle(null);
|
||||
}
|
||||
prevIsExpanded.current = isExpanded;
|
||||
}, [isExpanded]);
|
||||
|
||||
const expanded = manualToggle !== null ? manualToggle : isExpanded;
|
||||
|
||||
return (
|
||||
<Collapsible open={expanded} onOpenChange={(open) => setManualToggle(open)} className="mb-2">
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5 text-xs text-text-secondary hover:text-text-primary transition-colors cursor-pointer">
|
||||
<Expand size={3} isExpanded={expanded} />
|
||||
<span className="italic">Thinking</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-1 ml-[18px] text-xs text-text-secondary italic">
|
||||
<MarkdownContent content={content} />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -94,19 +94,39 @@ export function getTextAndImageContent(message: Message): {
|
||||
}
|
||||
}
|
||||
|
||||
// Strip <think> tags from assistant text — the thinking is surfaced via getThinkingContent
|
||||
if (message.role === 'assistant') {
|
||||
textContent = textContent.replace(/<think>[\s\S]*?<\/think>/gi, '');
|
||||
}
|
||||
|
||||
return { textContent, imagePaths };
|
||||
}
|
||||
|
||||
export function getThinkingContent(message: Message): string | null {
|
||||
const thinkingContents = message.content
|
||||
.filter((content) => content.type === 'thinking')
|
||||
.map((content) => {
|
||||
if ('thinking' in content) return content.thinking;
|
||||
return '';
|
||||
})
|
||||
.filter((text) => text.length > 0);
|
||||
const parts: string[] = [];
|
||||
|
||||
return thinkingContents.length > 0 ? thinkingContents.join('') : null;
|
||||
// Structured thinking content blocks
|
||||
for (const content of message.content) {
|
||||
if (content.type === 'thinking' && 'thinking' in content && content.thinking) {
|
||||
parts.push(content.thinking);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline <think> tags in assistant text content
|
||||
if (message.role === 'assistant') {
|
||||
for (const content of message.content) {
|
||||
if (content.type === 'text') {
|
||||
const regex = /<think>([\s\S]*?)<\/think>/gi;
|
||||
let match;
|
||||
while ((match = regex.exec(content.text)) !== null) {
|
||||
const text = match[1].trim();
|
||||
if (text) parts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join('') : null;
|
||||
}
|
||||
|
||||
export function getToolRequests(message: Message): (ToolRequest & { type: 'toolRequest' })[] {
|
||||
|
||||
Reference in New Issue
Block a user