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 (
+
+
+
+
+
+ )
+}
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 {