feat(ui): render thinking content blocks in message list

Add ThinkingItem component for displaying LLM extended thinking. Renders
as a collapsible row (matching ToolCallItem style) with expand to reveal
thinking text. MessageList now extracts and renders ThinkingContent blocks
before text content, matching the LLM output order.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-05 14:44:09 +08:00
parent 348b12b532
commit 0a6e930c2b
2 changed files with 117 additions and 3 deletions

View file

@ -4,15 +4,24 @@ import { memo, useMemo } from "react";
import { MemoizedMarkdown } from "@multica/ui/components/markdown";
import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown";
import { ToolCallItem } from "@multica/ui/components/tool-call-item";
import { ThinkingItem } from "@multica/ui/components/thinking-item";
import { cn, getTextContent } from "@multica/ui/lib/utils";
import type { Message } from "@multica/store";
import type { ContentBlock, ToolCall } from "@multica/sdk";
import type { ContentBlock, ToolCall, ThinkingContent } from "@multica/sdk";
/** Extract toolCall blocks from content */
function getToolCalls(blocks: ContentBlock[]): ToolCall[] {
return blocks.filter((b): b is ToolCall => b.type === "toolCall")
}
/** Extract concatenated thinking text from content blocks */
function getThinkingText(blocks: ContentBlock[]): string {
return blocks
.filter((b): b is ThinkingContent => b.type === "thinking")
.map((b) => b.thinking ?? "")
.join("")
}
/** Build a synthetic "running" toolResult Message from a ToolCall block */
function toRunningMessage(tc: ToolCall, agentId: string): Message {
return {
@ -55,17 +64,24 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }:
const text = getTextContent(msg.content)
const toolCalls = msg.role === "assistant" ? getToolCalls(msg.content) : []
const thinking = msg.role === "assistant" ? getThinkingText(msg.content) : ""
const hasThinkingBlocks = msg.role === "assistant" && msg.content.some((b) => b.type === "thinking")
const isStreaming = streamingIds.has(msg.id)
// Find toolCall blocks that don't have a toolResult message yet —
// these are tools the LLM decided to call but haven't started executing
const unresolvedToolCalls = toolCalls.filter((tc) => !resolvedToolCallIds.has(tc.id))
// Skip completely empty messages (no text, no unresolved tools, not streaming)
if (!text && unresolvedToolCalls.length === 0 && !isStreaming) return null
// Skip completely empty messages (no text, no unresolved tools, no thinking, not streaming)
if (!text && unresolvedToolCalls.length === 0 && !hasThinkingBlocks && !isStreaming) return null
return (
<div key={msg.id}>
{/* Render thinking content (before text, matching LLM output order) */}
{hasThinkingBlocks && (
<ThinkingItem thinking={thinking} isStreaming={isStreaming} />
)}
{/* Render text content (if any) */}
{(text || isStreaming) && (
<div

View file

@ -0,0 +1,98 @@
"use client"
import { memo, useState } from "react"
import { HugeiconsIcon } from "@hugeicons/react"
import {
AiBrain01Icon,
ArrowRight01Icon,
} from "@hugeicons/core-free-icons"
import { cn } from "@multica/ui/lib/utils"
interface ThinkingItemProps {
thinking: string
isStreaming?: boolean
}
export const ThinkingItem = memo(function ThinkingItem({ thinking, isStreaming }: ThinkingItemProps) {
const [expanded, setExpanded] = useState(false)
const hasContent = !!thinking
const isThinking = isStreaming && !hasContent
return (
<div className="py-0.5 px-2.5 text-sm text-muted-foreground">
<div className={cn("rounded transition-colors", expanded && "bg-muted/30")}>
<button
type="button"
aria-label={isThinking ? "Thinking" : "Thought"}
aria-expanded={hasContent ? expanded : undefined}
onClick={() => hasContent && setExpanded(!expanded)}
className={cn(
"group flex w-full items-center gap-1.5 rounded px-2.5 py-1",
"text-left transition-[color,background-color]",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 outline-none",
hasContent && !expanded && "hover:bg-muted/30 cursor-pointer",
hasContent && expanded && "cursor-pointer",
!hasContent && "cursor-default",
)}
>
{/* Status dot */}
<span
className={cn(
"size-1.5 rounded-full shrink-0",
isThinking
? "bg-[var(--tool-running)] motion-safe:animate-[glow-pulse_2s_ease-in-out_infinite]"
: "bg-[var(--tool-success)]",
)}
/>
{/* Icon */}
<HugeiconsIcon
icon={AiBrain01Icon}
strokeWidth={2}
className="size-3.5 shrink-0"
/>
{/* Label */}
<span className="font-medium shrink-0">
{isThinking ? "Thinking" : "Thought"}
</span>
{/* Running indicator */}
{isThinking && (
<span className="ml-auto text-xs text-muted-foreground/60 shrink-0 font-[tabular-nums] motion-safe:animate-pulse">
thinking
</span>
)}
{/* Chevron — visible on hover when expandable */}
{hasContent && (
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
className={cn(
"size-3 text-muted-foreground/40 shrink-0",
"transition-[transform,opacity] duration-150",
!isThinking && "ml-auto",
"opacity-0 group-hover:opacity-100",
expanded && "rotate-90 opacity-100",
)}
/>
)}
</button>
{/* Expanded thinking content */}
{expanded && thinking && (
<div
role="region"
aria-label="Thinking content"
tabIndex={0}
className="px-2.5 pt-1 pb-2 text-xs max-h-48 overflow-y-auto whitespace-pre-wrap break-words"
>
{thinking}
</div>
)}
</div>
</div>
)
})