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:
parent
348b12b532
commit
0a6e930c2b
2 changed files with 117 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
98
packages/ui/src/components/thinking-item.tsx
Normal file
98
packages/ui/src/components/thinking-item.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue