Welcome back, future AI-powered frontend wizard! In our previous chapters, you’ve mastered the fundamentals of integrating AI models, handling streaming responses, and even dabbling in prompt engineering. Now, it’s time to elevate your skills and build something truly powerful: an agent-driven UI workflow.

This chapter marks a significant leap from simple AI interactions to orchestrating intelligent agents that can perform multi-step tasks, make decisions, and even use “tools” to achieve a goal, all managed and displayed directly within your React or React Native application. You’ll learn how to build a user interface that not only interacts with an agent but actively participates in its workflow, displaying its thought process, executing its requested actions, and providing a rich, interactive experience. By the end of this project, you’ll have deep confidence in designing and implementing UIs that empower users with intelligent automation.

To get the most out of this chapter, ensure you’re comfortable with:

  • React’s core concepts: components, props, state (useState, useEffect).
  • Asynchronous JavaScript: async/await, Promises, fetch API.
  • Handling streaming data (as covered in previous chapters).
  • Basic understanding of prompt engineering and how AI models respond.

Let’s build an intelligent assistant together!

Core Concepts: Orchestrating Agents from the Frontend

Before we dive into code, let’s understand what an “agent-driven UI workflow” truly means and how the frontend plays a pivotal role.

What is an Agent-Driven UI Workflow?

Imagine an AI that doesn’t just answer a question, but can actually do things. This is the essence of an agent. An AI agent is a system that can:

  1. Understand a Goal: Interpret a user’s request.
  2. Plan: Break down the goal into a sequence of steps.
  3. Execute: Take actions, often by using “tools” (e.g., calling an API, searching a database, running code).
  4. Observe: See the results of its actions.
  5. Reflect & Replan: Adjust its plan based on observations, and iterate until the goal is met or it needs human input.
  6. Communicate: Explain its progress and final outcome.

An “agent-driven UI workflow” means your frontend application becomes the user’s window into this agent’s process. Instead of just displaying a final answer, the UI will show the agent’s thoughts, the tools it’s using, and the results of those tools, allowing for a much richer, more transparent, and often interactive experience.

The Agent-UI Orchestration Loop

Even if the core agent logic lives on a backend server (which is common for security and computational reasons), your frontend is responsible for orchestrating the interaction. Think of it as a continuous loop:

  1. User Input: The user provides a task to the UI.
  2. UI to Agent: The UI sends this task to the agent (via an API call).
  3. Agent Processing: The agent starts its internal “thought” process, potentially using tools.
  4. Agent to UI (Streaming): The agent streams its progress back to the UI. This might include:
    • Thoughts: “I need to find X, then use tool Y.”
    • Tool Calls: “I’m calling the ‘search_database’ tool with query ‘React Native AI’.”
    • Tool Results: “The search returned these results…”
    • Final Answer: “Here is the summary of your request.”
    • Request for User Input: “I need more information about Z.”
  5. UI Updates: The UI processes these streamed updates, displaying them to the user, potentially rendering interactive elements for tool calls, or prompting for more input.
  6. Repeat: If the agent needs more input or needs to execute another step, the loop continues.

Let’s visualize this flow:

flowchart TD User[User] -->|Provides Task| UI[Frontend Application] UI -->|Sends Task to Agent| AgentAPI(Agent API) AgentAPI --> Agent[AI Agent Logic] Agent -->|Streams Thoughts, Tool Calls, Results| AgentAPI AgentAPI -->|Streams to UI| UI UI -->|Displays Progress, Executes Tools, Prompts User| User UI -->|Sends Tool Results or New Input| AgentAPI

Figure 16.1: The Agent-UI Orchestration Loop

Tool Calling from the UI Perspective

A crucial aspect of agentic workflows is tool calling. An agent doesn’t directly perform actions like “send an email” or “update a database entry.” Instead, it identifies when a tool is needed, which tool, and what arguments to pass to it. It then communicates this “tool call” request.

From the frontend’s perspective, when the agent sends a tool_call instruction:

  • The UI must parse the tool name and its arguments.
  • It then needs to execute that tool. This often means making another API call to a different backend service (e.g., an email API, a CRM API) or even performing a client-side action (e.g., opening a modal, navigating to a different route).
  • Crucially, the UI must then send the result of that tool execution back to the agent. This allows the agent to continue its workflow, incorporating the tool’s output into its next thought step.

Security Note: Always remember that frontend code is client-side. Never expose sensitive API keys or credentials directly in your frontend for tool calls. If a tool requires authentication, it should typically be handled by a secure backend that the frontend calls, or through secure, short-lived tokens. The agent might request a tool, the frontend calls its own backend, which then securely calls the actual tool’s API.

Managing Agent State, Memory, and Context in React

For a smooth agent-driven UI, your React component needs to keep track of several pieces of information:

  • Conversation History (Memory): A list of all messages, agent thoughts, and tool calls/results. This is essential for the agent to maintain context across turns.
  • Current Agent Status: Is the agent thinking, calling a tool, waiting for user input, or finished?
  • Loading States: For API calls, tool executions.
  • Tool Definitions: A mapping of tool names to client-side functions that can execute them.

We’ll use React’s useState and useEffect hooks to manage this dynamic information effectively.

Streaming Agent Responses for Real-time Updates

Just as we learned with basic AI responses, agent workflows benefit immensely from streaming. Instead of waiting for the entire multi-step process to complete, the agent can send its thoughts, tool calls, and partial results in real-time. This keeps the user engaged and provides transparency into the agent’s progress. We’ll simulate this streaming behavior to demonstrate how your UI can react incrementally.

Step-by-Step Implementation: Building an Agentic Task Assistant UI

Let’s build a simple “Smart Note Taker” application. This application will allow a user to input a note, and an agent will categorize it, potentially suggest an action (which we’ll simulate as a “tool”), and then confirm.

Project Setup

First, let’s get our React project set up. We’ll use Vite for a fast and modern development experience.

  1. Create a new React project: Open your terminal and run:

    npm create vite@latest agent-note-taker -- --template react
    cd agent-note-taker
    npm install
    

    This creates a new React project named agent-note-taker using Vite.

  2. Clean up src/App.jsx: Replace the content of src/App.jsx with a minimal setup:

    // src/App.jsx
    import React from 'react';
    import './App.css'; // We'll keep the basic styling
    
    function App() {
      return (
        <div className="App">
          <h1>Smart Note Taker</h1>
          {/* Our Agent UI will go here */}
        </div>
      );
    }
    
    export default App;
    

    And for src/index.css, you can keep the default or simplify it for a cleaner look. Let’s add some basic styling to src/App.css to center things:

    /* src/App.css */
    #root {
      max-width: 1280px;
      margin: 0 auto;
      padding: 2rem;
      text-align: center;
    }
    
    .App {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 20px;
    }
    
    h1 {
      font-size: 2.5em;
      line-height: 1.1;
    }
    
  3. Run the app:

    npm run dev
    

    You should see “Smart Note Taker” in your browser.

Step 1: Basic UI & Input Component

Let’s create the main component that will house our agent interaction. We’ll call it AgentWorkflow.jsx.

  1. Create src/components/AgentWorkflow.jsx:

    // src/components/AgentWorkflow.jsx
    import React, { useState } from 'react';
    
    function AgentWorkflow() {
      const [input, setInput] = useState('');
      const [messages, setMessages] = useState([]); // To store conversation history
      const [isLoading, setIsLoading] = useState(false);
    
      const handleSendMessage = async () => {
        if (!input.trim()) return;
    
        const newUserMessage = { id: Date.now(), sender: 'user', text: input };
        setMessages((prevMessages) => [...prevMessages, newUserMessage]);
        setInput('');
        setIsLoading(true);
    
        // TODO: Call agent API here
        console.log("Sending to agent:", input);
        setIsLoading(false); // For now, turn off loading immediately
      };
    
      return (
        <div className="agent-workflow-container">
          <div className="message-list">
            {messages.map((msg) => (
              <div key={msg.id} className={`message ${msg.sender}`}>
                <strong>{msg.sender === 'user' ? 'You' : 'Agent'}:</strong> {msg.text}
              </div>
            ))}
            {isLoading && (
              <div className="message agent loading">
                <strong>Agent:</strong> Thinking...
              </div>
            )}
          </div>
    
          <div className="input-area">
            <textarea
              value={input}
              onChange={(e) => setInput(e.target.value)}
              placeholder="Enter your note here..."
              rows="4"
              disabled={isLoading}
            />
            <button onClick={handleSendMessage} disabled={isLoading}>
              {isLoading ? 'Processing...' : 'Send Note to Agent'}
            </button>
          </div>
        </div>
      );
    }
    
    export default AgentWorkflow;
    
  2. Add basic styling for the workflow component in src/App.css:

    /* src/App.css - add these styles */
    .agent-workflow-container {
      width: 100%;
      max-width: 600px;
      border: 1px solid #ccc;
      border-radius: 8px;
      padding: 20px;
      background-color: #f9f9f9;
      box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
    }
    
    .message-list {
      height: 300px;
      overflow-y: auto;
      border: 1px solid #eee;
      padding: 10px;
      margin-bottom: 15px;
      background-color: #fff;
      border-radius: 4px;
      text-align: left;
    }
    
    .message {
      margin-bottom: 10px;
      padding: 8px 12px;
      border-radius: 15px;
      max-width: 80%;
    }
    
    .message.user {
      background-color: #e0f7fa;
      align-self: flex-end;
      margin-left: auto;
      text-align: right;
    }
    
    .message.agent {
      background-color: #f0f0f0;
      align-self: flex-start;
      text-align: left;
    }
    
    .message.agent.loading {
      font-style: italic;
      color: #666;
    }
    
    .input-area {
      display: flex;
      flex-direction: column;
      gap: 10px;
    }
    
    textarea {
      width: 100%;
      padding: 10px;
      border: 1px solid #ddd;
      border-radius: 4px;
      resize: vertical;
      font-size: 1em;
    }
    
    button {
      padding: 10px 15px;
      background-color: #007bff;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 1em;
    }
    
    button:disabled {
      background-color: #a0c9ff;
      cursor: not-allowed;
    }
    
  3. Integrate AgentWorkflow into src/App.jsx:

    // src/App.jsx
    import React from 'react';
    import AgentWorkflow from './components/AgentWorkflow'; // Import our new component
    import './App.css';
    
    function App() {
      return (
        <div className="App">
          <h1>Smart Note Taker</h1>
          <AgentWorkflow /> {/* Render the workflow component */}
        </div>
      );
    }
    
    export default App;
    

    Now, when you run npm run dev, you’ll see the basic input and message area. Try typing a message; it will appear, and the “Thinking…” indicator will briefly show.

Step 2: Connecting to a Mock Agent API

Since our focus is on the frontend, we’ll create a mockAgentApi.js file that simulates an agent’s responses. This mock API will return a sequence of messages and tool calls, similar to what a real streaming agent API might send.

  1. Create src/api/mockAgentApi.js:

    // src/api/mockAgentApi.js
    // Simulates an agent API that streams responses including thoughts and tool calls.
    
    // A simple mock tool execution function
    const mockTools = {
      categorize_note: async (noteContent) => {
        console.log(`[MOCK TOOL] Categorizing note: "${noteContent.substring(0, 30)}..."`);
        await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API delay
        if (noteContent.toLowerCase().includes('meeting') || noteContent.toLowerCase().includes('agenda')) {
          return { category: 'Meeting Notes', summary: 'Key points from a meeting.' };
        }
        if (noteContent.toLowerCase().includes('idea') || noteContent.toLowerCase().includes('brainstorm')) {
          return { category: 'Ideas/Brainstorm', summary: 'Creative thoughts or concepts.' };
        }
        return { category: 'General', summary: 'A general, uncategorized note.' };
      },
      suggest_follow_up: async (category, summary) => {
        console.log(`[MOCK TOOL] Suggesting follow-up for category: ${category}`);
        await new Promise(resolve => setTimeout(resolve, 1500)); // Simulate API delay
        if (category === 'Meeting Notes') {
          return { action: 'Schedule follow-up meeting', details: 'Check participants availability.' };
        }
        if (category === 'Ideas/Brainstorm') {
          return { action: 'Create Trello card', details: 'Add idea to project backlog.' };
        }
        return { action: 'No specific follow-up', details: 'Just archive the note.' };
      }
    };
    
    // This function will simulate streaming responses from an agent.
    // It returns an async generator that yields chunks of agent output.
    async function* streamAgentResponse(prompt) {
      const messages = [
        { type: 'thought', content: `Okay, I received your note: "${prompt.substring(0, 50)}..."` },
        { type: 'thought', content: 'My first step is to categorize this note to understand its context.' },
        {
          type: 'tool_call',
          tool_name: 'categorize_note',
          tool_args: { noteContent: prompt }
        }
      ];
    
      for (const message of messages) {
        yield message;
        await new Promise(resolve => setTimeout(resolve, 700)); // Simulate network delay
      }
    
      // After tool call, simulate receiving tool result and then more agent thoughts/actions
      const categoryResult = await mockTools.categorize_note(prompt);
      yield { type: 'tool_result', tool_name: 'categorize_note', result: categoryResult };
      await new Promise(resolve => setTimeout(resolve, 700));
    
      yield { type: 'thought', content: `The note is categorized as "${categoryResult.category}". Now I'll suggest a follow-up action.` };
      await new Promise(resolve => setTimeout(resolve, 700));
    
      yield {
        type: 'tool_call',
        tool_name: 'suggest_follow_up',
        tool_args: { category: categoryResult.category, summary: categoryResult.summary }
      };
      await new Promise(resolve => setTimeout(resolve, 700));
    
      const followUpResult = await mockTools.suggest_follow_up(categoryResult.category, categoryResult.summary);
      yield { type: 'tool_result', tool_name: 'suggest_follow_up', result: followUpResult };
      await new Promise(resolve => setTimeout(resolve, 700));
    
      yield { type: 'thought', content: `Based on the category, I recommend the following: "${followUpResult.action}".` };
      await new Promise(resolve => setTimeout(resolve, 700));
    
      yield { type: 'final_answer', content: `Your note has been processed. Category: ${categoryResult.category}. Suggested Action: ${followUpResult.action}.` };
    }
    
    // A map of available tools for the frontend to execute
    export const availableTools = {
      categorize_note: mockTools.categorize_note, // In a real app, this would call a backend endpoint
      suggest_follow_up: mockTools.suggest_follow_up, // Same here
    };
    
    export default streamAgentResponse;
    

    Explanation:

    • mockTools: This object contains functions that simulate external “tools” an agent might use. Notice they are async to mimic real API calls.
    • streamAgentResponse(prompt): This is an async generator function. It’s the core of our mock agent.
      • It takes a prompt (the user’s note).
      • It yields a sequence of objects, each representing a chunk of the agent’s output. These objects have a type (thought, tool_call, tool_result, final_answer) and content or tool_name/tool_args.
      • await new Promise(resolve => setTimeout(resolve, ...)): This simulates network latency and processing time, making the “streaming” feel more realistic.
      • When a tool_call is yielded, the mock agent pauses its internal stream, waits for the tool to be “executed” (by calling mockTools internally), and then yields the tool_result before continuing. This mimics the agent’s internal loop.
    • availableTools: We export this object to allow our frontend to “execute” the mock tools when the agent requests them.

Step 3: Integrating the Mock Agent and Handling Streaming

Now, let’s update AgentWorkflow.jsx to interact with our mock agent. We’ll iterate through the streamed responses and update the UI.

  1. Update src/components/AgentWorkflow.jsx:

    // src/components/AgentWorkflow.jsx
    import React, { useState, useRef, useEffect } from 'react';
    import streamAgentResponse, { availableTools } from '../api/mockAgentApi'; // Import our mock agent
    
    function AgentWorkflow() {
      const [input, setInput] = useState('');
      const [messages, setMessages] = useState([]);
      const [isLoading, setIsLoading] = useState(false);
      const messageListRef = useRef(null); // Ref for auto-scrolling
    
      // Auto-scroll to bottom of messages
      useEffect(() => {
        if (messageListRef.current) {
          messageListRef.current.scrollTop = messageListRef.current.scrollHeight;
        }
      }, [messages]);
    
      const handleSendMessage = async () => {
        if (!input.trim()) return;
    
        const userMessageId = Date.now();
        const newUserMessage = { id: userMessageId, sender: 'user', text: input };
        setMessages((prevMessages) => [...prevMessages, newUserMessage]);
        setInput('');
        setIsLoading(true);
    
        const agentStream = streamAgentResponse(input);
        let currentAgentMessage = { id: userMessageId + 1, sender: 'agent', text: '' }; // Start with empty agent message
        setMessages((prevMessages) => [...prevMessages, currentAgentMessage]); // Add placeholder
    
        try {
          // Iterate over the async generator (simulated stream)
          for await (const chunk of agentStream) {
            // Update the last agent message or add a new one based on chunk type
            setMessages((prevMessages) => {
              const lastMessage = prevMessages[prevMessages.length - 1];
              let updatedMessages = [...prevMessages];
    
              if (chunk.type === 'thought' || chunk.type === 'final_answer') {
                // If the last message is an agent message, append to it. Otherwise, add new.
                if (lastMessage && lastMessage.sender === 'agent' && lastMessage.type !== 'tool_call_display' && lastMessage.type !== 'tool_result_display') {
                  updatedMessages[updatedMessages.length - 1] = {
                    ...lastMessage,
                    text: lastMessage.text + (lastMessage.text ? '\n' : '') + chunk.content,
                    type: chunk.type, // Update type for display
                  };
                } else {
                  updatedMessages.push({
                    id: Date.now(),
                    sender: 'agent',
                    text: chunk.content,
                    type: chunk.type,
                  });
                }
              } else if (chunk.type === 'tool_call') {
                // When agent requests a tool call, display it and execute
                const toolCallMessage = {
                  id: Date.now(),
                  sender: 'agent',
                  type: 'tool_call_display',
                  tool_name: chunk.tool_name,
                  tool_args: chunk.tool_args,
                  text: `Agent wants to call tool: ${chunk.tool_name}(${JSON.stringify(chunk.tool_args)})`
                };
                updatedMessages.push(toolCallMessage);
    
                // IMPORTANT: Execute the tool and send result back to the agent stream (conceptually)
                // In our mock, the agent stream already handles waiting for the tool result.
                // In a real system, you'd send the tool_result back to the agent API.
                // For this mock, we just display the tool execution and its result.
                const toolFunction = availableTools[chunk.tool_name];
                if (toolFunction) {
                  const toolResult = await toolFunction(...Object.values(chunk.tool_args));
                  const toolResultMessage = {
                    id: Date.now() + 1,
                    sender: 'agent',
                    type: 'tool_result_display',
                    tool_name: chunk.tool_name,
                    result: toolResult,
                    text: `Tool "${chunk.tool_name}" executed. Result: ${JSON.stringify(toolResult)}`
                  };
                  updatedMessages.push(toolResultMessage);
                } else {
                  const errorToolMessage = {
                    id: Date.now() + 1,
                    sender: 'agent',
                    type: 'error',
                    text: `Error: Agent requested unknown tool "${chunk.tool_name}"`
                  };
                  updatedMessages.push(errorToolMessage);
                }
              } else if (chunk.type === 'tool_result') {
                // We handle tool_result display within the tool_call block above for simplicity with mock.
                // In a real stream, agent would send this back. We just display it here.
                const toolResultMessage = {
                  id: Date.now(),
                  sender: 'agent',
                  type: 'tool_result_display',
                  tool_name: chunk.tool_name,
                  result: chunk.result,
                  text: `Agent received result for "${chunk.tool_name}": ${JSON.stringify(chunk.result)}`
                };
                updatedMessages.push(toolResultMessage);
              }
    
              return updatedMessages;
            });
          }
        } catch (error) {
          console.error("Error during agent stream:", error);
          setMessages((prevMessages) => [
            ...prevMessages,
            { id: Date.now(), sender: 'agent', type: 'error', text: `Error: ${error.message}` },
          ]);
        } finally {
          setIsLoading(false);
        }
      };
    
      // Helper to render different message types
      const renderMessageContent = (msg) => {
        if (msg.type === 'tool_call_display') {
          return (
            <>
              💡 Agent is planning to use tool `<strong>{msg.tool_name}</strong>` with arguments: <br/>
              <pre>{JSON.stringify(msg.tool_args, null, 2)}</pre>
              <span className="tool-status">Executing...</span>
            </>
          );
        }
        if (msg.type === 'tool_result_display') {
          return (
            <>
               Tool `<strong>{msg.tool_name}</strong>` completed. Result: <br/>
              <pre>{JSON.stringify(msg.result, null, 2)}</pre>
            </>
          );
        }
        if (msg.type === 'thought') {
          return <>💭 {msg.text}</>;
        }
        if (msg.type === 'final_answer') {
          return <> {msg.text}</>;
        }
        if (msg.type === 'error') {
          return <>🚨 {msg.text}</>;
        }
        return msg.text; // Default for user messages or generic agent text
      };
    
    
      return (
        <div className="agent-workflow-container">
          <div className="message-list" ref={messageListRef}>
            {messages.map((msg) => (
              <div key={msg.id} className={`message ${msg.sender} ${msg.type || ''}`}>
                <strong>{msg.sender === 'user' ? 'You' : 'Agent'}:</strong> {renderMessageContent(msg)}
              </div>
            ))}
            {isLoading && (
              <div className="message agent loading">
                <strong>Agent:</strong> Thinking...
              </div>
            )}
          </div>
    
          <div className="input-area">
            <textarea
              value={input}
              onChange={(e) => setInput(e.target.value)}
              placeholder="Enter your note here, e.g., 'Meeting agenda for Q1 planning. Need to discuss budget and new hires.'"
              rows="4"
              disabled={isLoading}
            />
            <button onClick={handleSendMessage} disabled={isLoading}>
              {isLoading ? 'Processing...' : 'Send Note to Agent'}
            </button>
          </div>
        </div>
      );
    }
    
    export default AgentWorkflow;
    

    Key Changes and Explanations:

    • Import streamAgentResponse and availableTools: We bring in our mock agent and its tools.
    • messageListRef and useEffect for Auto-Scrolling: This useRef and useEffect combination ensures that the message list automatically scrolls to the bottom as new messages are added, providing a smoother chat-like experience.
    • handleSendMessage Logic:
      • After adding the user’s message, we immediately add a placeholder agent message.
      • for await (const chunk of agentStream): This is where the magic happens! We iterate over the async generator returned by streamAgentResponse. Each chunk represents a piece of the agent’s output.
      • Dynamic setMessages updates:
        • For thought and final_answer types, we append the content to the last agent message in the messages array. This simulates the agent “typing” its thoughts or building up its final response.
        • For tool_call and tool_result types, we add distinct messages to the array, indicating the agent’s actions.
        • Tool Execution: When a tool_call chunk is received, we look up the corresponding function in availableTools and await its execution. The result is then added to the messages for display. In a real-world scenario, this toolFunction would likely make an fetch call to a backend endpoint that securely executes the actual tool.
    • renderMessageContent Helper: This new function helps us display different types of agent messages (thoughts, tool calls, tool results, final answers) with distinct formatting and icons, making the agent’s workflow transparent to the user.
    • Styling in src/App.css (add these):
      /* src/App.css - add these styles for tool display */
      .message.agent.tool_call_display,
      .message.agent.tool_result_display {
        background-color: #e6f7ff; /* Light blue background */
        border-left: 4px solid #007bff; /* Blue border */
        padding: 10px 15px;
      }
      
      .message pre {
        background-color: #f0f0f0;
        padding: 8px;
        border-radius: 4px;
        margin-top: 5px;
        overflow-x: auto;
        font-size: 0.9em;
        white-space: pre-wrap; /* Ensure pre-formatted text wraps */
      }
      
      .message .tool-status {
        font-style: italic;
        color: #555;
        margin-top: 5px;
        display: block;
      }
      

Now, try running npm run dev and enter a note like “Meeting agenda for Q1 planning. Need to discuss budget and new hires.” or “I have an idea for a new feature: a dark mode toggle!”. You’ll see the agent’s thoughts, tool calls, and results streaming in real-time!

Step 4: Refinement and Error Handling

Our current setup handles basic errors, but we can make it more robust. We’ll also add a way to conceptually “cancel” an ongoing agent process.

  1. Add a cancellationController: In a real application, you’d send a cancellation signal to your backend agent API. For our mock, we’ll just stop processing the stream on the frontend.

    // src/components/AgentWorkflow.jsx (add this to the top of the component)
    import React, { useState, useRef, useEffect } from 'react';
    import streamAgentResponse, { availableTools } from '../api/mockAgentApi';
    
    // ... (inside AgentWorkflow function)
    
    const abortControllerRef = useRef(null); // To manage cancellation
    
    const handleSendMessage = async () => {
      if (!input.trim()) return;
    
      // If an existing request is in progress, cancel it first (optional, but good UX)
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
        console.log("Previous agent process cancelled.");
        setIsLoading(false); // Reset loading state
        // Optionally, add a message to the UI about cancellation
        setMessages(prev => [...prev, { id: Date.now(), sender: 'system', type: 'info', text: 'Previous task cancelled.' }]);
      }
    
      abortControllerRef.current = new AbortController(); // Create a new controller for this request
      const signal = abortControllerRef.current.signal;
    
      // ... (rest of handleSendMessage function)
    
      try {
        // ... (inside for await loop)
        // Add a check for cancellation within the loop
        if (signal.aborted) {
            console.log("Agent stream aborted by user.");
            setMessages(prev => [...prev, { id: Date.now(), sender: 'system', type: 'info', text: 'Task aborted by user.' }]);
            break; // Exit the loop
        }
        // ... (rest of loop logic)
      } catch (error) {
        if (signal.aborted) {
            console.log("Agent stream caught aborted signal.");
            setMessages(prev => [...prev, { id: Date.now(), sender: 'system', type: 'info', text: 'Task aborted by user.' }]);
        } else {
            console.error("Error during agent stream:", error);
            setMessages((prevMessages) => [
                ...prevMessages,
                { id: Date.now(), sender: 'agent', type: 'error', text: `Error: ${error.message}` },
            ]);
        }
      } finally {
        setIsLoading(false);
        abortControllerRef.current = null; // Clear the controller after completion or cancellation
      }
    };
    
    const handleCancel = () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
        console.log("Agent process explicitly cancelled by user.");
      }
    };
    
    return (
      <div className="agent-workflow-container">
        {/* ... (message-list) */}
    
        <div className="input-area">
          <textarea
            value={input}
            onChange={(e) => setInput(e.target.value)}
            placeholder="Enter your note here, e.g., 'Meeting agenda for Q1 planning. Need to discuss budget and new hires.'"
            rows="4"
            disabled={isLoading}
          />
          <div className="action-buttons">
            <button onClick={handleSendMessage} disabled={isLoading}>
              {isLoading ? 'Processing...' : 'Send Note to Agent'}
            </button>
            {isLoading && (
              <button onClick={handleCancel} className="cancel-button">
                Cancel
              </button>
            )}
          </div>
        </div>
      </div>
    );
    

    Explanation of Cancellation:

    • AbortController: A standard browser API for aborting asynchronous operations. We create one for each agent request.
    • abortControllerRef: We store the controller in a ref so we can access it later to call abort().
    • handleSendMessage: If a previous request is active, we abort it before starting a new one. This prevents multiple agent calls from running concurrently and ensures a clean state.
    • for await loop: Inside the loop, we check signal.aborted. If true, we break the loop, effectively stopping frontend processing of the stream.
    • handleCancel function: A new function to be called by a “Cancel” button.
    • UI: Added a “Cancel” button that only appears when isLoading is true.
  2. Add action-buttons styling to src/App.css:

    /* src/App.css - add these styles for action buttons */
    .action-buttons {
      display: flex;
      gap: 10px;
      width: 100%;
    }
    
    .action-buttons button {
      flex: 1;
    }
    
    .action-buttons .cancel-button {
      background-color: #dc3545; /* Red for cancel */
    }
    
    .action-buttons .cancel-button:hover {
      background-color: #c82333;
    }
    
    .message.system.info {
      background-color: #e2e3e5;
      color: #383d41;
      font-style: italic;
      text-align: center;
      margin-left: auto;
      margin-right: auto;
    }
    

Now, you can type a note, click “Send,” and then click “Cancel” while the agent is processing. You’ll see the “Task aborted by user” message.

Mini-Challenge

Challenge: Enhance the suggest_follow_up tool. Instead of just suggesting an action, modify the mock agent to ask the user if they want to “Execute” the suggested action (e.g., “Schedule follow-up meeting”). If the user clicks “Execute,” the UI should then confirm that the action was taken.

Hint:

  1. In mockAgentApi.js, after the suggest_follow_up tool is called, modify the agent’s stream to yield a new type of message, e.g., type: 'user_action_request', which includes the suggested action.
  2. In AgentWorkflow.jsx, update renderMessageContent to recognize user_action_request. This should render a button (e.g., “Execute Action”).
  3. When the user clicks this button, the UI should send a new message back to the agent (conceptually, in our mock, it can just update the UI with a confirmation). In a real scenario, this would be a new API call to the agent, indicating the user’s choice. For simplicity, just update the UI state to show the action was “executed.”

What to observe/learn: This challenge pushes you to integrate user interaction mid-workflow, a critical pattern for agentic UIs. You’ll learn how to pause agent output, wait for user input, and then resume or complete the workflow based on that input.

Common Pitfalls & Troubleshooting

  1. Infinite Loops in Agent Responses:
    • Issue: The agent keeps sending messages or calling tools without reaching a final_answer.
    • Troubleshooting: This is usually a prompt engineering problem on the agent’s side (e.g., the agent doesn’t have clear termination conditions or gets stuck in a loop of thought/action). On the frontend, implement timeout mechanisms or max message count limits to prevent the UI from getting stuck indefinitely.
  2. Incorrectly Parsing Streamed Data:
    • Issue: The UI displays garbled text or crashes when processing agent responses.
    • Troubleshooting: Double-check the structure of the chunk objects from your agent stream (or mock stream). Ensure your setMessages logic correctly identifies the type and extracts the content, tool_name, tool_args, or result. Use console.log(chunk) to inspect the raw data.
  3. State Management Issues with Rapid Updates:
    • Issue: Messages appear out of order, or UI updates are janky during streaming.
    • Troubleshooting:
      • Ensure setMessages uses the functional update form (setMessages(prevMessages => [...])) to avoid stale closures, especially when updating based on the previous state.
      • For complex state, consider useReducer for more predictable state transitions.
      • If performance is an issue with many messages, explore virtualization libraries for long lists.
  4. Security: Exposing Sensitive Information:
    • Issue: Accidentally putting API keys or confidential data directly in client-side tool calls or logs.
    • Troubleshooting: Never put secrets in frontend code. All sensitive tool execution should route through a secure backend service. Even tool arguments should be sanitized and validated before sending to a backend.
  5. Lack of Visual Feedback:
    • Issue: The user doesn’t know if the agent is working, waiting, or stuck.
    • Troubleshooting: Always provide clear loading indicators, “thinking” messages, and specific UI elements (like our tool call/result displays) to show the agent’s current status and progress.

Summary

Congratulations! You’ve successfully built an agent-driven UI workflow, a cornerstone of advanced AI-powered applications. Let’s recap what you’ve achieved:

  • Understood Agentic Workflows: You now grasp the difference between simple AI calls and multi-step agent processes that use tools.
  • Mastered Client-Side Orchestration: You learned how the frontend acts as the conductor for an AI agent, managing its state, displaying its progress, and executing its tool requests.
  • Implemented Streaming & Dynamic UI Updates: You built a UI that reacts in real-time to agent thoughts, tool calls, and results, providing a transparent and engaging user experience.
  • Handled Tool Calling: You integrated the concept of agent-requested tool execution from the frontend, understanding the flow of input, execution, and result reporting.
  • Managed Agent State: You utilized React’s useState and useRef to maintain conversation history, agent status, and control asynchronous operations like cancellation.
  • Improved User Experience: You added auto-scrolling and visual cues to make the agent’s process clear and interactive.

This project lays a robust foundation for building sophisticated AI copilots, smart assistants, and automated task systems directly within your React and React Native applications. You’re now equipped to design UIs that not only consume AI but actively participate in and enhance agentic workflows.

What’s Next?

In the upcoming chapters, we’ll continue to refine our agentic UI patterns, focusing on:

  • Real Agent Integration: Connecting to actual backend agent services (e.g., using OpenAI Assistants API or custom agent frameworks).
  • Advanced Guardrails and Safety Checks: Implementing more sophisticated frontend validation and UX protections for agent outputs and tool calls.
  • Performance Optimization: Ensuring smooth experiences even with complex agent interactions and large message histories.
  • Accessibility: Making agent-driven UIs usable for everyone.

Keep experimenting, keep building, and get ready to unlock even more potential in your AI-powered frontend journey!


References

  1. React Official Documentation: The definitive guide for React concepts and hooks.
  2. Vite Official Documentation: Learn more about the build tool used for our project setup.
  3. MDN Web Docs - AbortController: Understand the browser API for cancelling asynchronous operations.
  4. Hugging Face Transformers.js (for client-side AI concepts): While we didn’t use it directly in this chapter’s project, Transformers.js is excellent for understanding in-browser AI model execution, which agents can leverage client-side.
  5. OpenAI - Agentic Tool Calling (Conceptual): Provides a good overview of how agents use tools, which informs our frontend’s interaction model.

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.