
In our previous guide on writing a production custom tool and connecting it to your AI agent, we built inspectLiveGitHubIssue with a 5-second timeout and DTO payload shaping so DevPulse can inspect live upstream bug reports. Giving an agent external API tools makes it far more capable, but it introduces a thorny frontend UX problem: when an agent chains a documentation search, a live GitHub API call, and a final synthesis step, four to eight seconds can pass before the first sentence of the final answer appears on screen.
To render accurate thinking and loading states for an AI agent in React and Next.js, you combine the Vercel AI SDK's request lifecycle (status: 'submitted' | 'streaming' | 'ready' | 'error'), granular toolInvocation states ('partial-call', 'call', and 'result'), and custom server-streamed data events (dataStream.writeData) into a deterministic state machine. Instead of displaying a generic spinning circle, this pattern renders a collapsible, zero-CLS Thinking Timeline that tells the user which tool is executing, what human-friendly action is happening, and how much time has elapsed.
Users forgive a five-second wait when they can see an agent actively flipping through documentation pages and verifying issue #402. They abandon the app when they stare at a frozen 'Loading...' label and wonder whether their browser tab crashed.
Why a Generic Spinner Fails for Multi-Step AI Agents
In a traditional CRUD application, loading is binary: a fetch request starts (isLoading = true) and finishes (isLoading = false). In a multi-step AI agent powered by streamText and useChat, a single user turn actually passes through four distinct latency phases before final text tokens stream:
- Phase 1 — Intent Routing (
status === 'submitted'): The user hits Send. The browser sends the payload to/api/chat, the server sanitizes conversation history, and the LLM evaluates which tool to pick first. Neither text nor tool calls have arrived yet (~300–700ms). - Phase 2 — Tool Argument Generation (
toolInvocation.state === 'partial-call'): The LLM has chosen a tool (such asinspectLiveGitHubIssue) and is actively streaming the JSON arguments (repository,issueNumber) token by token. - Phase 3 — Server Tool Execution (
toolInvocation.state === 'call'): The server has validated the Zod schema and is now waiting for the external GitHub REST API or Supabase vector query to resolve (500ms–5,000ms). - Phase 4 — Post-Tool Synthesis Gap (
toolInvocation.state === 'result'whilem.content === ''): The tool finished and returned its JSON observation, and the server has sent that JSON back to OpenAI or Claude on Step 2. For the next 600–1,200 milliseconds, all tools show'result', yetm.contentis still empty while the model formulates its prose answer.
If your React UI only checks if (isLoading) to show a spinner—or worse, hides the loading indicator the instant a tool reaches state === 'result'—your interface will flicker, jump, and leave dead gaps between steps.

Mapping the Agent UI State Machine
To eliminate dead air and layout jumps, our frontend needs a single source of truth that translates raw SDK states and server data frames into human-readable progress steps.
Here is how the four latency phases transition inside our React UI state machine:
mermaid stateDiagram-v2 [*] --> Idle: status = ready Idle --> Routing: User submits prompt (status = submitted) Routing --> DraftingArgs: LLM emits toolInvocation (state = partial-call) DraftingArgs --> ExecutingTool: Zod validated, server runs tool (state = call) ExecutingTool --> Synthesizing: Tool returns JSON (state = result, content empty) Synthesizing --> DraftingArgs: Agent chains Step 2 tool Synthesizing --> StreamingText: First text delta arrives Routing --> StreamingText: No tool needed, direct text delta StreamingText --> Idle: Stream completes (onFinish)
Architecture Flow Summary: When the user submits a prompt, the UI transitions from Idle to Routing (status = 'submitted'). If the agent invokes tools, the UI progresses through DraftingArgs (state = 'partial-call'), ExecutingTool (state = 'call'), and Synthesizing (state = 'result' with empty message content), looping back if a second tool is chained, before finally entering StreamingText once text tokens arrive and returning to Idle on completion.
Step 1: Streaming Granular Server Phase Events from /api/chat
While m.toolInvocations automatically tracks tool execution, what if your server performs work before the LLM emits its first token—such as sanitizing history or checking rate limits—or transitions between reasoning steps? Using dataStream.writeData() inside src/app/api/chat/route.ts, we can push real-time server status breadcrumbs down the exact same HTTP stream.
Let us update src/app/api/chat/route.ts to emit structured agent-phase events at the start of the request and at the end of each reasoning step:
`typescript // src/app/api/chat/route.ts import { NextRequest, NextResponse } from 'next/server'; import { createDataStreamResponse } from 'ai'; import { z } from 'zod'; import { defaultDevPulseConfig } from '@/lib/agent/blueprint'; import { type ChatMessage } from '@/lib/agent/llm-client'; import { createAgentTextStream } from '@/lib/agent/stream-client';
export const maxDuration = 60;
const routePayloadSchema = z.object({ sessionId: z.string().default('devpulse-default-session'), modelId: z .enum(['gpt-4o', 'claude-3-5-sonnet-latest']) .default('claude-3-5-sonnet-latest'), messages: z .array( z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string().min(1), }) ) .min(1), });
export interface AgentPhaseEvent { type: 'agent-phase'; phase: 'sanitizing' | 'reasoning' | 'step-completed' | 'synthesizing'; detail: string; timestamp: number; }
export async function POST(req: NextRequest) { try { const body = await req.json(); const parsed = routePayloadSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid chat request payload.' },
{ status: 400 }
);
}
const { messages, modelId } = parsed.data;
const normalizedMessages: ChatMessage[] = messages.filter(
(m): m is { role: 'user' | 'assistant'; content: string } =>
m.role === 'user' || m.role === 'assistant'
);
return createDataStreamResponse({
execute: (dataStream) => {
dataStream.writeData({
type: 'agent-phase',
phase: 'reasoning',
detail: `Routing query with ${modelId}...`,
timestamp: Date.now(),
});
const result = createAgentTextStream(
normalizedMessages,
{
...defaultDevPulseConfig,
modelId,
},
(telemetry) => {
dataStream.writeMessageAnnotation({
modelUsed: telemetry.modelUsed,
promptTokens: telemetry.promptTokens,
completionTokens: telemetry.completionTokens,
latencyMs: telemetry.latencyMs,
stepCount: telemetry.stepCount,
toolsCalled: telemetry.toolsCalled,
});
}
);
result.mergeIntoDataStream(dataStream);
},
onError: (error) =>
error instanceof Error ? error.message : 'Stream failed on server.',
});
} catch (err) { const message = err instanceof Error ? err.message : 'Unexpected server error'; return NextResponse.json({ error: message }, { status: 500 }); } } `
Step 2: Deriving Human-Friendly Thinking Labels (src/lib/agent/ui-state.ts)
Raw tool names like searchKnowledgeBase or inspectLiveGitHubIssue and raw JSON arguments like {'repository':'vercel/ai','issueNumber':402} look robotic if you dump them straight onto the screen while the user is waiting. Worse, keeping UI derivation logic inside a 300-line JSX file makes your component hard to test.
Let us create a clean TypeScript state-derivation helper in src/lib/agent/ui-state.ts that maps tool names and lifecycle states into clear, developer-friendly status labels:
`typescript // src/lib/agent/ui-state.ts import { type Message } from 'ai';
export type ThinkingStage = | 'idle' | 'routing' | 'preparing-tool' | 'executing-tool' | 'synthesizing' | 'streaming-answer';
export interface DerivedThinkingState { isThinking: boolean; stage: ThinkingStage; headline: string; subtext: string; completedToolsCount: number; totalToolsCount: number; }
const TOOL_FRIENDLY_LABELS: Record< string, { preparing: string; executing: (args: Record<string, unknown>) => string }
= { searchKnowledgeBase: { preparing: 'Formulating documentation search query...', executing: (args) =>
Searching internal docs for '${String(args?.query ?? 'technical specs').slice(0, 45)}'..., }, checkIssueTracker: { preparing: 'Preparing issue tracker filter...', executing: (args) =>Checking ${String(args?.status ?? 'open')} issues matching '${String(args?.keyword ?? '')}'..., }, inspectLiveGitHubIssue: { preparing: 'Targeting upstream GitHub repository...', executing: (args) =>Fetching live GitHub issue ${String(args?.repository ?? '')}#${String(args?.issueNumber ?? '')}..., }, draftIssueTicket: { preparing: 'Structuring bug report fields and reproduction steps...', executing: (args) =>Drafting ${String(args?.severity ?? 'medium')} severity ticket for ${String(args?.component ?? 'system')}..., }, };
export function deriveAgentThinkingState( messages: Message[], isLoading: boolean ): DerivedThinkingState { if (!isLoading) { return { isThinking: false, stage: 'idle', headline: 'Ready', subtext: '', completedToolsCount: 0, totalToolsCount: 0, }; }
const lastMessage = messages[messages.length - 1];
if (!lastMessage || lastMessage.role === 'user') { return { isThinking: true, stage: 'routing', headline: 'Analyzing request & selecting tools...', subtext: 'Evaluating system prompt rules and conversation context', completedToolsCount: 0, totalToolsCount: 0, }; }
const invocations = lastMessage.toolInvocations ?? []; const completedCount = invocations.filter((t) => t.state === 'result').length; const activeTool = invocations.find((t) => t.state !== 'result');
if (activeTool) { const labelConfig = TOOL_FRIENDLY_LABELS[activeTool.toolName]; const isPartial = activeTool.state === 'partial-call'; const safeArgs = activeTool.args && typeof activeTool.args === 'object' ? (activeTool.args as Record<string, unknown>) : {};
return {
isThinking: true,
stage: isPartial ? 'preparing-tool' : 'executing-tool',
headline: labelConfig
? isPartial
? labelConfig.preparing
: labelConfig.executing(safeArgs)
: `Running ${activeTool.toolName}...`,
subtext: `Step ${completedCount + 1}: Executing server-side tool boundary`,
completedToolsCount: completedCount,
totalToolsCount: invocations.length,
};
}
if (invocations.length > 0 && (!lastMessage.content || lastMessage.content.trim().length === 0)) {
return {
isThinking: true,
stage: 'synthesizing',
headline: 'Synthesizing tool observations into final response...',
subtext: Processed ${completedCount} tool output(s) • Verifying technical accuracy,
completedToolsCount: completedCount,
totalToolsCount: invocations.length,
};
}
return { isThinking: false, stage: 'streaming-answer', headline: 'Streaming response...', subtext: '', completedToolsCount: completedCount, totalToolsCount: invocations.length, }; } `
Look closely at that fourth branch (if (invocations.length > 0 && !lastMessage.content)). That single condition catches the Post-Tool Synthesis Gap: when all tools in Step 1 have reached state === 'result', but the LLM has not yet emitted the first character of the final answer on Step 2. Instead of hiding the thinking box and showing a blank gap, it smoothly transitions the headline to 'Synthesizing tool observations into final response...'.
Step 3: Building the Collapsible AgentThinkingTimeline Component
Now let us build src/components/AgentThinkingTimeline.tsx. This component renders a live elapsed timer (in tenths of a second) while the agent is actively thinking, displays each tool step as a timeline node, and automatically collapses into a compact summary bar once text streaming begins—so you never cause jarring Cumulative Layout Shift (CLS).
`tsx // src/components/AgentThinkingTimeline.tsx 'use client';
import { useEffect, useState } from 'react'; import { type ToolInvocation } from 'ai'; import { type DerivedThinkingState } from '@/lib/agent/ui-state';
interface AgentThinkingTimelineProps { thinkingState: DerivedThinkingState; toolInvocations?: ToolInvocation[]; }
export default function AgentThinkingTimeline({ thinkingState, toolInvocations = [], }: AgentThinkingTimelineProps) { const [elapsedTenths, setElapsedTenths] = useState(0); const [isExpanded, setIsExpanded] = useState(true);
useEffect(() => { if (!thinkingState.isThinking) { if (toolInvocations.length > 0) { setIsExpanded(false); } return; }
setIsExpanded(true);
const interval = setInterval(() => {
setElapsedTenths((prev) => prev + 1);
}, 100);
return () => clearInterval(interval);
}, [thinkingState.isThinking, toolInvocations.length]);
if (!thinkingState.isThinking && toolInvocations.length === 0) { return null; }
const formattedSeconds = (elapsedTenths / 10).toFixed(1);
return ( <button type='button' onClick={() => setIsExpanded((prev) => !prev)} className='w-full px-3.5 py-2.5 flex items-center justify-between text-left hover:bg-slate-900/60' > {thinkingState.isThinking ? ( ) : ( )}
<span className='text-xs font-medium text-cyan-300 truncate'>
{thinkingState.isThinking
? thinkingState.headline
: `Thought for ${formattedSeconds}s across ${toolInvocations.length} tool step(s)`}
</span>
</div>
<div className='flex items-center gap-2 shrink-0 ml-3'>
<span className='text-[11px] font-mono text-slate-400 bg-slate-900 px-2 py-0.5 rounded border border-slate-800'>
{formattedSeconds}s
</span>
<span className='text-xs text-slate-500'>
{isExpanded ? '▴' : '▾'}
</span>
</div>
</button>
{isExpanded && (
<div className='px-3.5 pb-3 pt-1 border-t border-slate-900 space-y-2 text-xs'>
{thinkingState.subtext && (
<p className='text-slate-400 text-[11px]'>{thinkingState.subtext}</p>
)}
{toolInvocations.length > 0 && (
<div className='space-y-1.5 pt-1'>
{toolInvocations.map((inv, index) => {
const isDone = inv.state === 'result';
const isPartial = inv.state === 'partial-call';
return (
<div
key={inv.toolCallId}
className='flex items-center justify-between py-1 px-2.5 rounded bg-slate-900/80 border border-slate-800/80 font-mono text-[11px]'
>
<div className='flex items-center gap-2 truncate'>
<span
className={
isDone
? 'text-emerald-400'
: isPartial
? 'text-amber-400'
: 'text-cyan-400 animate-pulse'
}
>
{isDone ? '✓' : isPartial ? '⋯' : '⚡'}
</span>
<span className='text-slate-200'>
Step {index + 1}: {inv.toolName}
</span>
</div>
<span className='text-[10px] text-slate-400 ml-2 shrink-0'>
{isDone
? 'Observation ready'
: isPartial
? 'Streaming args...'
: 'Running on server...'}
</span>
</div>
);
})}
</div>
)}
</div>
)}
</div>
); } `
Integrating AgentThinkingTimeline into our DevPulseStreamChat.tsx component takes just two lines: compute const thinkingState = deriveAgentThinkingState(messages, isLoading); at the top of the component, and render AgentThinkingTimeline inside the active assistant message bubble.

A Real UI Debugging Lesson: The 1.8-Second 'Dead Air' Flicker Between Tool Result and Text Stream
When I was refining the asynchronous search states and modal transitions in Movie Explorer and GitHub Issues Tracker, loading indicators were straightforward: show a skeleton while isLoading is true, and swap in the data grid the millisecond the JSON arrives. When I applied that exact mental model to my first multi-step DevPulse prototype, I created an annoying UI flicker that took me several screen recordings to diagnose.
In my early chat component, I rendered a 'Running tool...' banner only while toolInvocation.state === 'call'. As soon as inspectLiveGitHubIssue returned its JSON from GitHub, toolInvocation.state flipped to 'result', and my banner immediately unmounted. However, because streamText now had to send that 180-token GitHub DTO back up to Claude 3.5 Sonnet on Step 2 to generate the natural-language answer, there was a 1.4-to-1.8 second pause before the first word of m.content arrived!
During those 1.8 seconds, the user saw the loading indicator vanish, saw an empty message box, thought the agent had finished with a blank response, and started clicking the Send button again—only for the text stream to suddenly burst onto the screen a moment later. Adding the explicit 'synthesizing' stage inside deriveAgentThinkingState() (which stays active whenever invocations.length > 0 && !lastMessage.content) bridged that gap completely. Even better, collapsing the timeline header automatically to 'Thought for 3.4s across 2 tool step(s)' once m.content starts streaming preserved the full diagnostic history without pushing the answer off the screen.
Common Mistakes When Building Thinking States for AI Agents
Before we wrap up Phase 2 with end-to-end TypeScript safety in Post 21, make sure your UI avoids these four loading-state traps:
1. Unmounting the Thinking Indicator Instead of Collapsing It
If you render a 140-pixel-tall multi-step thinking box while the agent runs two tools, and then completely unmount it (return null) the millisecond the first text character arrives, the entire chat message jumps upward by 140 pixels while the user is trying to read sentence one. That creates jarring Cumulative Layout Shift (CLS). Instead, keep the header bar mounted and collapse the inner drawer (setIsExpanded(false)) so the user can expand it anytime to inspect how long the agent thought.
2. Displaying Raw partial-call JSON Strings to End Users
While inv.state === 'partial-call', the LLM is still streaming the JSON arguments character by character. If you render JSON.stringify(inv.args) directly in your main status banner during partial-call, users see broken fragments flashing rapidly. Use human-friendly lookup dictionaries like TOOL_FRIENDLY_LABELS so the headline stays clean and readable.
3. Leaving Elapsed Timers Running After stop() or Stream Errors
If you start a setInterval timer when the user submits a message and forget to tie its cleanup to thinkingState.isThinking (which turns false when isLoading becomes false after stop() or an error), the timer will keep counting up forever in the background. Always clear the interval in the useEffect cleanup function.
4. Blocking the Input Box Without Showing Which Phase Is Active
Disabling the Send button during a 7-second multi-step tool run without showing step progression makes the app feel frozen. Pairing the live AgentThinkingTimeline with the amber Stop button we built in Post 16 gives the user both visibility and control at every millisecond of the loop.
Frequently Asked Questions
What is the difference between toolInvocation.state === 'partial-call' and 'call' in the Vercel AI SDK?
'partial-call' means the LLM has decided which tool to invoke and is still streaming the JSON parameters token by token from OpenAI or Anthropic. 'call' means the complete JSON arguments have been parsed and validated against your Zod schema, and your Next.js server is now actively executing the async execute() function.
How do I prevent Cumulative Layout Shift (CLS) when transitioning from the thinking state to the streamed answer?
Instead of destroying the thinking component when text streaming begins, keep the top summary bar (Thought for 3.2s across 2 tool steps) permanently mounted at the top of the assistant message bubble and simply collapse the expandable step details list. Reserving a consistent minimum height on the active message container also prevents viewport jumping.
Can I show real-time progress from inside a long-running custom tool before it finishes?
Yes. By passing the dataStream handle from createDataStreamResponse down into your tool execution flow and calling dataStream.writeData({ type: 'agent-phase', detail: '...' }), your server can stream intermediate status messages (such as 'Connected to GitHub API, parsing comments...') to the data array in useChat even while a single tool is still running.
Need a High-Performance Web App or Custom AI Solution?
I help founders, businesses, and engineering teams build lightning-fast web applications, resilient backend architectures, and intelligent AI workflows. Have an idea in mind? Let’s bring it to life.
Found this article helpful?
Give some claps to support more in-depth engineering logs!
Zahid Hasan Tonmoy
Author & Developer
MERN Full Stack Developer & AI Agent Developer based in Dhaka, Bangladesh. Writing about web development, React, PostgreSQL and my learning journey.