diff --git a/web/src/chat/normalize.ts b/web/src/chat/normalize.ts index e3f2d863..f661fc04 100644 --- a/web/src/chat/normalize.ts +++ b/web/src/chat/normalize.ts @@ -281,7 +281,7 @@ function normalizeAgentRecord( const data = isObject(content.data) ? content.data : null if (!data || typeof data.type !== 'string') return null - if ((data.type === 'message' || data.type === 'reasoning') && typeof data.message === 'string') { + if (data.type === 'message' && typeof data.message === 'string') { return { id: messageId, localId, @@ -293,6 +293,18 @@ function normalizeAgentRecord( } } + if (data.type === 'reasoning' && typeof data.message === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ type: 'reasoning', text: data.message, uuid: messageId, parentUUID: null }], + meta + } + } + if (data.type === 'tool-call' && typeof data.callId === 'string') { const uuid = asString(data.id) ?? messageId return { diff --git a/web/src/chat/reconcile.ts b/web/src/chat/reconcile.ts index 9e9b37ea..bab31926 100644 --- a/web/src/chat/reconcile.ts +++ b/web/src/chat/reconcile.ts @@ -1,6 +1,7 @@ import type { AgentEvent, AgentEventBlock, + AgentReasoningBlock, AgentTextBlock, ChatBlock, CliOutputBlock, @@ -106,6 +107,13 @@ function areAgentTextBlocksEqual(left: AgentTextBlock, right: AgentTextBlock): b && left.meta === right.meta } +function areAgentReasoningBlocksEqual(left: AgentReasoningBlock, right: AgentReasoningBlock): boolean { + return left.text === right.text + && left.localId === right.localId + && left.createdAt === right.createdAt + && left.meta === right.meta +} + function areCliOutputBlocksEqual(left: CliOutputBlock, right: CliOutputBlock): boolean { return left.text === right.text && left.localId === right.localId @@ -187,6 +195,11 @@ function reconcileBlock(block: ChatBlock, prevById: ChatBlocksById): ChatBlock { return areCliOutputBlocksEqual(prevBlock, block) ? prevBlock : block } + if (block.kind === 'agent-reasoning') { + const prevBlock = prev as AgentReasoningBlock + return areAgentReasoningBlocksEqual(prevBlock, block) ? prevBlock : block + } + const prevBlock = prev as AgentEventBlock return areAgentEventBlocksEqual(prevBlock, block) ? prevBlock : block } diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 83650786..66b5574b 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -420,6 +420,18 @@ function reduceTimeline( continue } + if (c.type === 'reasoning') { + blocks.push({ + kind: 'agent-reasoning', + id: `${msg.id}:${idx}`, + localId: msg.localId, + createdAt: msg.createdAt, + text: c.text, + meta: msg.meta + }) + continue + } + if (c.type === 'summary') { blocks.push({ kind: 'agent-event', diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index fe06e2af..6f0472b9 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -51,6 +51,12 @@ export type NormalizedAgentContent = uuid: string parentUUID: string | null } + | { + type: 'reasoning' + text: string + uuid: string + parentUUID: string | null + } | ToolUse | ToolResult | { type: 'summary'; summary: string } @@ -122,6 +128,15 @@ export type AgentTextBlock = { meta?: unknown } +export type AgentReasoningBlock = { + kind: 'agent-reasoning' + id: string + localId: string | null + createdAt: number + text: string + meta?: unknown +} + export type CliOutputBlock = { kind: 'cli-output' id: string @@ -150,4 +165,4 @@ export type ToolCallBlock = { meta?: unknown } -export type ChatBlock = UserTextBlock | AgentTextBlock | CliOutputBlock | ToolCallBlock | AgentEventBlock +export type ChatBlock = UserTextBlock | AgentTextBlock | AgentReasoningBlock | CliOutputBlock | ToolCallBlock | AgentEventBlock diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx index 13d76dfe..0f73893a 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -1,5 +1,6 @@ import { MessagePrimitive, useAssistantState } from '@assistant-ui/react' import { MarkdownText } from '@/components/assistant-ui/markdown-text' +import { Reasoning, ReasoningGroup } from '@/components/assistant-ui/reasoning' import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessage' import { CliOutputBlock } from '@/components/CliOutputBlock' import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' @@ -10,6 +11,8 @@ const TOOL_COMPONENTS = { const MESSAGE_PART_COMPONENTS = { Text: MarkdownText, + Reasoning: Reasoning, + ReasoningGroup: ReasoningGroup, tools: TOOL_COMPONENTS } as const diff --git a/web/src/components/assistant-ui/reasoning.tsx b/web/src/components/assistant-ui/reasoning.tsx new file mode 100644 index 00000000..25ebb8a0 --- /dev/null +++ b/web/src/components/assistant-ui/reasoning.tsx @@ -0,0 +1,101 @@ +import { useState, useEffect, type FC, type PropsWithChildren } from 'react' +import { useMessage } from '@assistant-ui/react' +import { MarkdownTextPrimitive } from '@assistant-ui/react-markdown' +import { cn } from '@/lib/utils' +import { defaultComponents, MARKDOWN_PLUGINS } from '@/components/assistant-ui/markdown-text' + +function ChevronIcon(props: { className?: string; open?: boolean }) { + return ( + + + + ) +} + +function ShimmerDot() { + return ( + + ) +} + +/** + * Renders individual reasoning message part content with markdown support. + */ +export const Reasoning: FC = () => { + return ( + + ) +} + +/** + * Wraps consecutive reasoning parts in a collapsible container. + * Shows shimmer effect while reasoning is streaming. + */ +export const ReasoningGroup: FC = ({ children }) => { + const [isOpen, setIsOpen] = useState(false) + + // Check if reasoning is still streaming + const message = useMessage() + const isStreaming = message.status?.type === 'running' + && message.content.length > 0 + && message.content[message.content.length - 1]?.type === 'reasoning' + + // Auto-expand while streaming + useEffect(() => { + if (isStreaming) { + setIsOpen(true) + } + }, [isStreaming]) + + return ( +
+ + +
+
+ {children} +
+
+
+ ) +} diff --git a/web/src/lib/assistant-runtime.ts b/web/src/lib/assistant-runtime.ts index e42327ec..1d78c7dd 100644 --- a/web/src/lib/assistant-runtime.ts +++ b/web/src/lib/assistant-runtime.ts @@ -58,6 +58,19 @@ function toThreadMessageLike(block: ChatBlock): ThreadMessageLike { } } + if (block.kind === 'agent-reasoning') { + const messageId = `assistant:${block.id}` + return { + role: 'assistant', + id: messageId, + createdAt: new Date(block.createdAt), + content: [{ type: 'reasoning', text: block.text }], + metadata: { + custom: { kind: 'assistant' } satisfies HappyChatMessageMetadata + } + } + } + if (block.kind === 'agent-event') { const messageId = `event:${block.id}` return {