refactor: enhance chat and tool UI with improved streaming and error handling

- Updated NewChatPage to persist partial responses when requests are cancelled by the user.
- Enhanced WriteTodosToolUI to check if the thread is running, improving the display of loading states and error handling.
- Modified Plan and TodoItem components to conditionally animate in-progress items based on streaming status, providing a clearer user experience during task management.
This commit is contained in:
Anish Sarkar 2025-12-26 15:01:22 +05:30
parent 2860b789e3
commit 5c68820c2a
3 changed files with 51 additions and 20 deletions

View file

@ -30,19 +30,27 @@ import type { PlanTodo, TodoStatus } from "./schema";
interface StatusIconProps {
status: TodoStatus;
className?: string;
/** When false, in_progress items show as static (no spinner) */
isStreaming?: boolean;
}
const StatusIcon: FC<StatusIconProps> = ({ status, className }) => {
const StatusIcon: FC<StatusIconProps> = ({ status, className, isStreaming = true }) => {
const baseClass = cn("size-4 shrink-0", className);
switch (status) {
case "completed":
return <CheckCircle2 className={cn(baseClass, "text-emerald-500")} />;
case "in_progress":
// Only animate the spinner if we're actively streaming
// When streaming is stopped, show as a static dashed circle
return (
<CircleDashed
className={cn(baseClass, "text-primary animate-spin")}
style={{ animationDuration: "3s" }}
className={cn(
baseClass,
"text-primary",
isStreaming && "animate-spin"
)}
style={isStreaming ? { animationDuration: "3s" } : undefined}
/>
);
case "cancelled":
@ -59,18 +67,21 @@ const StatusIcon: FC<StatusIconProps> = ({ status, className }) => {
interface TodoItemProps {
todo: PlanTodo;
/** When false, in_progress items show as static (no spinner/pulse) */
isStreaming?: boolean;
}
const TodoItem: FC<TodoItemProps> = ({ todo }) => {
const TodoItem: FC<TodoItemProps> = ({ todo, isStreaming = true }) => {
const isStrikethrough = todo.status === "completed" || todo.status === "cancelled";
const isShimmer = todo.status === "in_progress";
// Only show pulse animation if streaming and in progress
const isShimmer = todo.status === "in_progress" && isStreaming;
if (todo.description) {
return (
<AccordionItem value={todo.id} className="border-0">
<AccordionTrigger className="py-2 hover:no-underline">
<div className="flex items-center gap-2">
<StatusIcon status={todo.status} />
<StatusIcon status={todo.status} isStreaming={isStreaming} />
<span
className={cn(
"text-sm text-left",
@ -91,7 +102,7 @@ const TodoItem: FC<TodoItemProps> = ({ todo }) => {
return (
<div className="flex items-center gap-2 py-2">
<StatusIcon status={todo.status} />
<StatusIcon status={todo.status} isStreaming={isStreaming} />
<span
className={cn(
"text-sm",
@ -116,6 +127,8 @@ export interface PlanProps {
todos: PlanTodo[];
maxVisibleTodos?: number;
showProgress?: boolean;
/** When false, in_progress items show as static (no spinner/pulse animations) */
isStreaming?: boolean;
responseActions?: Action[] | ActionsConfig;
className?: string;
onResponseAction?: (actionId: string) => void;
@ -129,6 +142,7 @@ export const Plan: FC<PlanProps> = ({
todos,
maxVisibleTodos = 4,
showProgress = true,
isStreaming = true,
responseActions,
className,
onResponseAction,
@ -176,7 +190,7 @@ export const Plan: FC<PlanProps> = ({
return (
<Accordion type="single" collapsible className="w-full">
{items.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
<TodoItem key={todo.id} todo={todo} isStreaming={isStreaming} />
))}
</Accordion>
);
@ -185,7 +199,7 @@ export const Plan: FC<PlanProps> = ({
return (
<div className="space-y-0">
{items.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
<TodoItem key={todo.id} todo={todo} isStreaming={isStreaming} />
))}
</div>
);

View file

@ -1,6 +1,6 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { makeAssistantToolUI, useAssistantState } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo } from "react";
@ -91,6 +91,10 @@ export const WriteTodosToolUI = makeAssistantToolUI<WriteTodosArgs, WriteTodosRe
render: function WriteTodosUI({ args, result, status, toolCallId }) {
const updatePlanState = useSetAtom(updatePlanStateAtom);
const planStates = useAtomValue(planStatesAtom);
// Check if the THREAD is running (not just this tool)
// This hook subscribes to state changes, so it re-renders when thread stops
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
// Get the plan data (from result or args)
const planData = result || transformArgsToResult(args);
@ -140,7 +144,7 @@ export const WriteTodosToolUI = makeAssistantToolUI<WriteTodosArgs, WriteTodosRe
return null;
}
// Loading state - tool is still running
// Loading state - tool is still running (no data yet)
if (status.type === "running" || status.type === "requires-action") {
// Try to show partial results from args while streaming
const partialResult = transformArgsToResult(args);
@ -149,7 +153,7 @@ export const WriteTodosToolUI = makeAssistantToolUI<WriteTodosArgs, WriteTodosRe
return (
<div className="my-4">
<PlanErrorBoundary>
<Plan {...plan} showProgress={true} />
<Plan {...plan} showProgress={true} isStreaming={isThreadRunning} />
</PlanErrorBoundary>
</div>
);
@ -159,17 +163,15 @@ export const WriteTodosToolUI = makeAssistantToolUI<WriteTodosArgs, WriteTodosRe
// Incomplete/cancelled state
if (status.type === "incomplete") {
if (status.reason === "cancelled") {
return null;
}
// For errors, try to show what we have from args or shared state
// For cancelled or errors, try to show what we have from args or shared state
// Use isThreadRunning to determine if we should still animate
const fallbackResult = currentPlanState || transformArgsToResult(args);
if (fallbackResult) {
const plan = parseSerializablePlan(fallbackResult);
return (
<div className="my-4">
<PlanErrorBoundary>
<Plan {...plan} showProgress={true} />
<Plan {...plan} showProgress={true} isStreaming={isThreadRunning} />
</PlanErrorBoundary>
</div>
);
@ -178,7 +180,8 @@ export const WriteTodosToolUI = makeAssistantToolUI<WriteTodosArgs, WriteTodosRe
}
// Success - render the plan using the LATEST shared state
// This way, even if our result is old, we show the latest data
// Use isThreadRunning to determine if we should animate in_progress items
// (LLM may still be working on tasks even though this tool call completed)
const planToRender = currentPlanState || result;
if (!planToRender) {
return <WriteTodosLoading />;
@ -188,7 +191,7 @@ export const WriteTodosToolUI = makeAssistantToolUI<WriteTodosArgs, WriteTodosRe
return (
<div className="my-4">
<PlanErrorBoundary>
<Plan {...plan} showProgress={true} />
<Plan {...plan} showProgress={true} isStreaming={isThreadRunning} />
</PlanErrorBoundary>
</div>
);