fix(agent): improve chat message rendering (#56)

Refactor ChatMessage to use AI SDK type guards and
render parts in natural order. Collapse reasoning by
default so thinking tokens don't flood the screen.
Cap reasoning content height during streaming. Add
tool workflow guidance to system prompt.

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
Nicholai 2026-02-07 13:28:32 -07:00 committed by GitHub
parent b24f94e570
commit a25c8a26bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 275 additions and 152 deletions

View File

@ -103,6 +103,7 @@ export async function POST(req: Request): Promise<Response> {
...githubTools, ...githubTools,
...pluginTools, ...pluginTools,
}, },
toolChoice: "auto",
stopWhen: stepCountIs(10), stopWhen: stepCountIs(10),
onError({ error }) { onError({ error }) {
const apiErr = unwrapAPICallError(error) const apiErr = unwrapAPICallError(error)

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useCallback, useRef, useEffect } from "react" import { useState, useCallback, useRef, useEffect, memo } from "react"
import { import {
CopyIcon, CopyIcon,
ThumbsUpIcon, ThumbsUpIcon,
@ -20,9 +20,20 @@ import {
IconAlertCircle, IconAlertCircle,
IconEye, IconEye,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import type { ToolUIPart } from "ai" import {
isTextUIPart,
isToolUIPart,
isReasoningUIPart,
type UIMessage,
type ToolUIPart,
type DynamicToolUIPart,
} from "ai"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import {
Reasoning,
ReasoningTrigger,
ReasoningContent,
} from "@/components/ai/reasoning"
import { import {
Conversation, Conversation,
ConversationContent, ConversationContent,
@ -160,24 +171,37 @@ function friendlyToolName(raw: string): string {
return TOOL_DISPLAY_NAMES[raw] ?? raw return TOOL_DISPLAY_NAMES[raw] ?? raw
} }
// shared message + tool rendering for both variants interface ChatMessageProps {
function ChatMessage({ readonly msg: UIMessage
readonly copiedId: string | null
readonly onCopy: (id: string, text: string) => void
readonly onRegenerate: () => void
readonly isStreaming?: boolean
}
type AnyToolPart = ToolUIPart | DynamicToolUIPart
function extractToolName(part: AnyToolPart): string {
if (part.type === "dynamic-tool") {
return part.toolName ?? ""
}
return part.type.slice(5)
}
// renders parts in their natural order from the AI SDK
const ChatMessage = memo(
function ChatMessage({
msg, msg,
copiedId, copiedId,
onCopy, onCopy,
onRegenerate, onRegenerate,
}: { isStreaming: msgStreaming = false,
readonly msg: { }: ChatMessageProps) {
readonly id: string
readonly role: string
readonly parts: ReadonlyArray<unknown>
}
readonly copiedId: string | null
onCopy: (id: string, text: string) => void
onRegenerate: () => void
}) {
if (msg.role === "user") { if (msg.role === "user") {
const text = getTextContent(msg.parts) const text = msg.parts
.filter(isTextUIPart)
.map((p) => p.text)
.join("")
return ( return (
<Message from="user"> <Message from="user">
<MessageContent>{text}</MessageContent> <MessageContent>{text}</MessageContent>
@ -185,53 +209,85 @@ function ChatMessage({
) )
} }
const textParts: string[] = [] // walk parts sequentially, flushing text when
const toolParts: Array<{ // hitting a tool or reasoning part to preserve
type: string // interleaving. text flushed before the final
state: ToolUIPart["state"] // segment is "thinking" (intermediate chain-of-
toolName: string // thought) and rendered muted + collapsible.
input: unknown const elements: React.ReactNode[] = []
output: unknown let pendingText = ""
errorText?: string let allText = ""
}> = [] let pendingReasoning = ""
let reasoningStreaming = false
for (const part of msg.parts) { let sawToolPart = false
const p = part as Record<string, unknown>
if (p.type === "text" && typeof p.text === "string") { const flushThinking = (
textParts.push(p.text) text: string,
} idx: number,
const pType = p.type as string | undefined streaming = false
// handle static (tool-<name>) and dynamic ) => {
// (dynamic-tool) tool parts if (!text) return
if ( elements.push(
typeof pType === "string" && <Reasoning
(pType.startsWith("tool-") || key={`think-${idx}`}
pType === "dynamic-tool") isStreaming={streaming}
) { defaultOpen={false}
// extract tool name from type field or toolName >
const rawName = pType.startsWith("tool-") <ReasoningTrigger />
? pType.slice(5) <ReasoningContent>{text}</ReasoningContent>
: ((p.toolName ?? "") as string) </Reasoning>
toolParts.push({ )
type: pType,
state: p.state as ToolUIPart["state"],
toolName:
friendlyToolName(rawName) || "Working",
input: p.input,
output: p.output,
errorText: p.errorText as string | undefined,
})
}
} }
const text = textParts.join("") const flushText = (idx: number, isFinal: boolean) => {
if (!pendingText) return
if (!isFinal) {
// intermediate text before more tools = thinking
flushThinking(pendingText, idx)
} else {
elements.push(
<MessageContent key={`text-${idx}`}>
<MessageResponse>
{pendingText}
</MessageResponse>
</MessageContent>
)
}
pendingText = ""
}
return ( for (let i = 0; i < msg.parts.length; i++) {
<Message from="assistant"> const part = msg.parts[i]
{toolParts.map((tp, i) => (
<Tool key={i}> if (isReasoningUIPart(part)) {
pendingReasoning += part.text
reasoningStreaming = part.state === "streaming"
continue
}
if (isTextUIPart(part)) {
pendingText += part.text
allText += part.text
continue
}
if (isToolUIPart(part)) {
sawToolPart = true
// flush reasoning accumulated before this tool
flushThinking(pendingReasoning, i, reasoningStreaming)
pendingReasoning = ""
reasoningStreaming = false
// flush text as thinking (not final)
flushText(i, false)
const tp = part as AnyToolPart
const rawName = extractToolName(tp)
elements.push(
<Tool key={tp.toolCallId}>
<ToolHeader <ToolHeader
title={tp.toolName} title={
friendlyToolName(rawName) || "Working"
}
type={tp.type as ToolUIPart["type"]} type={tp.type as ToolUIPart["type"]}
state={tp.state} state={tp.state}
/> />
@ -246,16 +302,44 @@ function ChatMessage({
)} )}
</ToolContent> </ToolContent>
</Tool> </Tool>
))} )
{text ? ( }
<> }
<MessageContent>
<MessageResponse>{text}</MessageResponse> // flush remaining reasoning
</MessageContent> flushThinking(
pendingReasoning,
msg.parts.length,
reasoningStreaming
)
// while streaming, if no tool calls have arrived yet
// and text is substantial, it's likely chain-of-thought
// that'll be reclassified as thinking once tools come in.
// render it collapsed so it doesn't flood the screen.
const COT_THRESHOLD = 500
if (
msgStreaming &&
!sawToolPart &&
pendingText.length > COT_THRESHOLD
) {
flushThinking(pendingText, msg.parts.length, true)
pendingText = ""
}
// flush remaining text as the final response
flushText(msg.parts.length, true)
const hasContent = elements.length > 0
return (
<Message from="assistant">
{hasContent ? elements : <Loader />}
{allText && (
<Actions> <Actions>
<Action <Action
tooltip="Copy" tooltip="Copy"
onClick={() => onCopy(msg.id, text)} onClick={() => onCopy(msg.id, allText)}
> >
{copiedId === msg.id ? ( {copiedId === msg.id ? (
<Check className="size-4" /> <Check className="size-4" />
@ -276,25 +360,23 @@ function ChatMessage({
<RefreshCcwIcon className="size-4" /> <RefreshCcwIcon className="size-4" />
</Action> </Action>
</Actions> </Actions>
</>
) : (
<Loader />
)} )}
</Message> </Message>
) )
} },
(prev, next) => {
function getTextContent( if (prev.msg !== next.msg) return false
parts: ReadonlyArray<unknown> if (prev.onCopy !== next.onCopy) return false
): string { if (prev.onRegenerate !== next.onRegenerate)
return (parts as ReadonlyArray<{ type: string; text?: string }>) return false
.filter( if (prev.isStreaming !== next.isStreaming)
(p): p is { type: "text"; text: string } => return false
p.type === "text" const prevCopied = prev.copiedId === prev.msg.id
) const nextCopied = next.copiedId === next.msg.id
.map((p) => p.text) if (prevCopied !== nextCopied) return false
.join("") return true
} }
)
function ChatInput({ function ChatInput({
textareaRef, textareaRef,
@ -533,6 +615,21 @@ export function ChatView({ variant }: ChatViewProps) {
[isPage, chat.sendMessage] [isPage, chat.sendMessage]
) )
const handleIdleSend = useCallback(
(text: string) => {
setIsActive(true)
chat.sendMessage({ text })
},
[chat.sendMessage]
)
const handleActiveSend = useCallback(
(text: string) => {
chat.sendMessage({ text })
},
[chat.sendMessage]
)
const suggestions = isPage const suggestions = isPage
? DASHBOARD_SUGGESTIONS ? DASHBOARD_SUGGESTIONS
: getSuggestionsForPath(chat.pathname) : getSuggestionsForPath(chat.pathname)
@ -593,10 +690,7 @@ export function ChatView({ variant }: ChatViewProps) {
recorder={recorder} recorder={recorder}
status={chat.status} status={chat.status}
isGenerating={chat.isGenerating} isGenerating={chat.isGenerating}
onSend={(text) => { onSend={handleIdleSend}
setIsActive(true)
chat.sendMessage({ text })
}}
className="rounded-2xl" className="rounded-2xl"
/> />
@ -652,13 +746,19 @@ export function ChatView({ variant }: ChatViewProps) {
{chat.messages.length > 0 ? ( {chat.messages.length > 0 ? (
<Conversation className="flex-1"> <Conversation className="flex-1">
<ConversationContent className="mx-auto w-full max-w-3xl"> <ConversationContent className="mx-auto w-full max-w-3xl">
{chat.messages.map((msg) => ( {chat.messages.map((msg, idx) => (
<ChatMessage <ChatMessage
key={msg.id} key={msg.id}
msg={msg} msg={msg}
copiedId={copiedId} copiedId={copiedId}
onCopy={handleCopy} onCopy={handleCopy}
onRegenerate={chat.regenerate} onRegenerate={chat.regenerate}
isStreaming={
(chat.status === "streaming" ||
chat.status === "submitted") &&
idx === chat.messages.length - 1 &&
msg.role === "assistant"
}
/> />
))} ))}
</ConversationContent> </ConversationContent>
@ -698,9 +798,7 @@ export function ChatView({ variant }: ChatViewProps) {
recorder={recorder} recorder={recorder}
status={chat.status} status={chat.status}
isGenerating={chat.isGenerating} isGenerating={chat.isGenerating}
onSend={(text) => onSend={handleActiveSend}
chat.sendMessage({ text })
}
onNewChat={chat.messages.length > 0 ? chat.newChat : undefined} onNewChat={chat.messages.length > 0 ? chat.newChat : undefined}
className="rounded-2xl" className="rounded-2xl"
/> />
@ -729,13 +827,19 @@ export function ChatView({ variant }: ChatViewProps) {
</Suggestions> </Suggestions>
</div> </div>
) : ( ) : (
chat.messages.map((msg) => ( chat.messages.map((msg, idx) => (
<ChatMessage <ChatMessage
key={msg.id} key={msg.id}
msg={msg} msg={msg}
copiedId={copiedId} copiedId={copiedId}
onCopy={handleCopy} onCopy={handleCopy}
onRegenerate={chat.regenerate} onRegenerate={chat.regenerate}
isStreaming={
(chat.status === "streaming" ||
chat.status === "submitted") &&
idx === chat.messages.length - 1 &&
msg.role === "assistant"
}
/> />
)) ))
)} )}
@ -751,9 +855,7 @@ export function ChatView({ variant }: ChatViewProps) {
recorder={recorder} recorder={recorder}
status={chat.status} status={chat.status}
isGenerating={chat.isGenerating} isGenerating={chat.isGenerating}
onSend={(text) => onSend={handleActiveSend}
chat.sendMessage({ text })
}
onNewChat={chat.messages.length > 0 ? chat.newChat : undefined} onNewChat={chat.messages.length > 0 ? chat.newChat : undefined}
/> />
</div> </div>

View File

@ -3,7 +3,7 @@
import { useControllableState } from "@radix-ui/react-use-controllable-state" import { useControllableState } from "@radix-ui/react-use-controllable-state"
import { BrainIcon, ChevronDownIcon } from "lucide-react" import { BrainIcon, ChevronDownIcon } from "lucide-react"
import type { ComponentProps, ReactNode } from "react" import type { ComponentProps, ReactNode } from "react"
import { createContext, memo, useContext, useEffect, useState } from "react" import { createContext, memo, useContext, useEffect, useRef, useState } from "react"
import { Streamdown } from "streamdown" import { Streamdown } from "streamdown"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -34,7 +34,7 @@ export type ReasoningProps = ComponentProps<typeof Collapsible> & {
duration?: number duration?: number
} }
const AUTO_CLOSE_DELAY = 1000 const CLOSE_GRACE_MS = 300
const MS_IN_S = 1000 const MS_IN_S = 1000
export const Reasoning = memo( export const Reasoning = memo(
@ -73,14 +73,13 @@ export const Reasoning = memo(
} }
}, [isStreaming, startTime, setDuration]) }, [isStreaming, startTime, setDuration])
// Auto-open when streaming starts, auto-close when streaming ends (once only) // close gracefully once streaming finishes
useEffect(() => { useEffect(() => {
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) { if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
// Add a small delay before closing to allow user to see the content
const timer = setTimeout(() => { const timer = setTimeout(() => {
setIsOpen(false) setIsOpen(false)
setHasAutoClosed(true) setHasAutoClosed(true)
}, AUTO_CLOSE_DELAY) }, CLOSE_GRACE_MS)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
@ -154,7 +153,18 @@ export type ReasoningContentProps = ComponentProps<typeof CollapsibleContent> &
children: string children: string
} }
export const ReasoningContent = memo(({ className, children, ...props }: ReasoningContentProps) => ( export const ReasoningContent = memo(({ className, children, ...props }: ReasoningContentProps) => {
const { isStreaming } = useReasoning()
const scrollRef = useRef<HTMLDivElement>(null)
// auto-scroll to show latest tokens during streaming
useEffect(() => {
if (isStreaming && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [children, isStreaming])
return (
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
"mt-4 text-sm", "mt-4 text-sm",
@ -162,10 +172,16 @@ export const ReasoningContent = memo(({ className, children, ...props }: Reasoni
className, className,
)} )}
{...props} {...props}
>
<div
ref={scrollRef}
className={cn(isStreaming && "max-h-[3.75rem] overflow-hidden")}
> >
<Streamdown {...props}>{children}</Streamdown> <Streamdown {...props}>{children}</Streamdown>
</div>
</CollapsibleContent> </CollapsibleContent>
)) )
})
Reasoning.displayName = "Reasoning" Reasoning.displayName = "Reasoning"
ReasoningTrigger.displayName = "ReasoningTrigger" ReasoningTrigger.displayName = "ReasoningTrigger"

View File

@ -5,22 +5,6 @@ import type { PromptSection } from "@/lib/agent/plugins/types"
type PromptMode = "full" | "minimal" | "none" type PromptMode = "full" | "minimal" | "none"
type ToolCategory =
| "data"
| "navigation"
| "ui"
| "memory"
| "github"
| "skills"
| "feedback"
interface ToolMeta {
readonly name: string
readonly summary: string
readonly category: ToolCategory
readonly adminOnly?: true
}
interface DashboardSummary { interface DashboardSummary {
readonly id: string readonly id: string
readonly name: string readonly name: string
@ -38,6 +22,22 @@ interface PromptContext {
readonly mode?: PromptMode readonly mode?: PromptMode
} }
type ToolCategory =
| "data"
| "navigation"
| "ui"
| "memory"
| "github"
| "skills"
| "feedback"
interface ToolMeta {
readonly name: string
readonly summary: string
readonly category: ToolCategory
readonly adminOnly?: true
}
interface DerivedState { interface DerivedState {
readonly mode: PromptMode readonly mode: PromptMode
readonly page: string readonly page: string
@ -665,6 +665,10 @@ function buildGuidelines(
return [ return [
...core, ...core,
"- Tool workflow: data requests -> queryData immediately. " +
"Navigation -> navigateTo, brief confirmation. " +
"Dashboards -> queryData first, then generateUI. " +
"Memories -> save proactively with rememberContext.",
'- "How\'s development going?" means fetch repo_stats and ' + '- "How\'s development going?" means fetch repo_stats and ' +
'recent commits right now, not "Would you like to see ' + 'recent commits right now, not "Would you like to see ' +
'commits or PRs?"', 'commits or PRs?"',