feat: enhance chat functionality with chain-of-thought display and thinking steps management

This commit is contained in:
Anish Sarkar 2025-12-22 22:54:22 +05:30
parent 2f622891ae
commit 8a99752f2f
8 changed files with 857 additions and 48 deletions

View file

@ -7,10 +7,13 @@ import {
MessagePrimitive,
ThreadPrimitive,
useAssistantState,
useMessage,
} from "@assistant-ui/react";
import {
ArrowDownIcon,
ArrowUpIcon,
Brain,
CheckCircle2,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
@ -19,6 +22,8 @@ import {
Loader2,
PencilIcon,
RefreshCwIcon,
Search,
Sparkles,
SquareIcon,
} from "lucide-react";
import Image from "next/image";
@ -32,44 +37,134 @@ import {
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtItem,
ChainOfThoughtStep,
ChainOfThoughtTrigger,
} from "@/components/prompt-kit/chain-of-thought";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
export const Thread: FC = () => {
/**
* Props for the Thread component
*/
interface ThreadProps {
messageThinkingSteps?: Map<string, ThinkingStep[]>;
}
// Context to pass thinking steps to AssistantMessage
import { createContext, useContext } from "react";
const ThinkingStepsContext = createContext<Map<string, ThinkingStep[]>>(new Map());
/**
* Get icon based on step status and title
*/
function getStepIcon(status: "pending" | "in_progress" | "completed", title: string) {
const titleLower = title.toLowerCase();
if (status === "in_progress") {
return <Loader2 className="size-4 animate-spin text-primary" />;
}
if (status === "completed") {
return <CheckCircle2 className="size-4 text-emerald-500" />;
}
if (titleLower.includes("search") || titleLower.includes("knowledge")) {
return <Search className="size-4 text-muted-foreground" />;
}
if (titleLower.includes("analy") || titleLower.includes("understand")) {
return <Brain className="size-4 text-muted-foreground" />;
}
return <Sparkles className="size-4 text-muted-foreground" />;
}
/**
* Chain of thought display component
*/
const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[] }> = ({ steps }) => {
if (steps.length === 0) return null;
return (
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full flex-col bg-background"
style={{
["--thread-max-width" as string]: "44rem",
}}
>
<ThreadPrimitive.Viewport
turnAnchor="top"
className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
<ChainOfThought>
{steps.map((step) => {
const icon = getStepIcon(step.status, step.title);
return (
<ChainOfThoughtStep
key={step.id}
defaultOpen={step.status === "in_progress"}
>
<ChainOfThoughtTrigger
leftIcon={icon}
swapIconOnHover={step.status !== "in_progress"}
className={cn(
step.status === "in_progress" && "text-foreground font-medium",
step.status === "completed" && "text-muted-foreground"
)}
>
{step.title}
</ChainOfThoughtTrigger>
{step.items && step.items.length > 0 && (
<ChainOfThoughtContent>
{step.items.map((item, index) => (
<ChainOfThoughtItem key={`${step.id}-item-${index}`}>
{item}
</ChainOfThoughtItem>
))}
</ChainOfThoughtContent>
)}
</ChainOfThoughtStep>
);
})}
</ChainOfThought>
</div>
);
};
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) => {
return (
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full flex-col bg-background"
style={{
["--thread-max-width" as string]: "44rem",
}}
>
<AssistantIf condition={({ thread }) => thread.isEmpty}>
<ThreadWelcome />
</AssistantIf>
<ThreadPrimitive.Messages
components={{
UserMessage,
EditComposer,
AssistantMessage,
}}
/>
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
<ThreadScrollToBottom />
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
<Composer />
</div>
<ThreadPrimitive.Viewport
turnAnchor="top"
className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
>
<AssistantIf condition={({ thread }) => thread.isEmpty}>
<ThreadWelcome />
</AssistantIf>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
<ThreadPrimitive.Messages
components={{
UserMessage,
EditComposer,
AssistantMessage,
}}
/>
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
<ThreadScrollToBottom />
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
<Composer />
</div>
</AssistantIf>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
</ThinkingStepsContext.Provider>
);
};
@ -197,7 +292,7 @@ const ThreadWelcome: FC = () => {
const Composer: FC = () => {
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow has-[textarea:focus-visible]:border-ring has-[textarea:focus-visible]:ring-2 has-[textarea:focus-visible]:ring-ring/20 data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
<ComposerAttachments />
<ComposerPrimitive.Input
placeholder="Ask SurfSense"
@ -297,12 +392,22 @@ const MessageError: FC = () => {
);
};
const AssistantMessage: FC = () => {
const AssistantMessageInner: FC = () => {
const thinkingStepsMap = useContext(ThinkingStepsContext);
// Get the current message ID to look up thinking steps
const messageId = useMessage((m) => m.id);
const thinkingSteps = thinkingStepsMap.get(messageId) || [];
return (
<MessagePrimitive.Root
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
data-role="assistant"
>
<>
{/* Show thinking steps BEFORE the text response */}
{thinkingSteps.length > 0 && (
<div className="mb-3">
<ThinkingStepsDisplay steps={thinkingSteps} />
</div>
)}
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
<MessagePrimitive.Parts
components={{
@ -317,6 +422,17 @@ const AssistantMessage: FC = () => {
<BranchPicker />
<AssistantActionBar />
</div>
</>
);
};
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
data-role="assistant"
>
<AssistantMessageInner />
</MessagePrimitive.Root>
);
};

View file

@ -0,0 +1,148 @@
"use client"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
import { Brain, ChevronDown, Circle, Loader2, Search, Sparkles, Lightbulb, CheckCircle2 } from "lucide-react"
import React from "react"
export type ChainOfThoughtItemProps = React.ComponentProps<"div">
export const ChainOfThoughtItem = ({
children,
className,
...props
}: ChainOfThoughtItemProps) => (
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
{children}
</div>
)
export type ChainOfThoughtTriggerProps = React.ComponentProps<
typeof CollapsibleTrigger
> & {
leftIcon?: React.ReactNode
swapIconOnHover?: boolean
}
export const ChainOfThoughtTrigger = ({
children,
className,
leftIcon,
swapIconOnHover = true,
...props
}: ChainOfThoughtTriggerProps) => (
<CollapsibleTrigger
className={cn(
"group text-muted-foreground hover:text-foreground flex cursor-pointer items-center justify-start gap-1 text-left text-sm transition-colors",
className
)}
{...props}
>
<div className="flex items-center gap-2">
{leftIcon ? (
<span className="relative inline-flex size-4 items-center justify-center">
<span
className={cn(
"transition-opacity",
swapIconOnHover && "group-hover:opacity-0"
)}
>
{leftIcon}
</span>
{swapIconOnHover && (
<ChevronDown className="absolute size-4 opacity-0 transition-opacity group-hover:opacity-100 group-data-[state=open]:rotate-180" />
)}
</span>
) : (
<span className="relative inline-flex size-4 items-center justify-center">
<Circle className="size-2 fill-current" />
</span>
)}
<span>{children}</span>
</div>
{!leftIcon && (
<ChevronDown className="size-4 transition-transform group-data-[state=open]:rotate-180" />
)}
</CollapsibleTrigger>
)
export type ChainOfThoughtContentProps = React.ComponentProps<
typeof CollapsibleContent
>
export const ChainOfThoughtContent = ({
children,
className,
...props
}: ChainOfThoughtContentProps) => {
return (
<CollapsibleContent
className={cn(
"text-popover-foreground data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden",
className
)}
{...props}
>
<div className="grid grid-cols-[min-content_minmax(0,1fr)] gap-x-4">
<div className="bg-primary/20 ml-1.75 h-full w-px group-data-[last=true]:hidden" />
<div className="ml-1.75 h-full w-px bg-transparent group-data-[last=false]:hidden" />
<div className="mt-2 space-y-2">{children}</div>
</div>
</CollapsibleContent>
)
}
export type ChainOfThoughtProps = {
children: React.ReactNode
className?: string
}
export function ChainOfThought({ children, className }: ChainOfThoughtProps) {
const childrenArray = React.Children.toArray(children)
return (
<div className={cn("space-y-0", className)}>
{childrenArray.map((child, index) => (
<React.Fragment key={index}>
{React.isValidElement(child) &&
React.cloneElement(
child as React.ReactElement<ChainOfThoughtStepProps>,
{
isLast: index === childrenArray.length - 1,
}
)}
</React.Fragment>
))}
</div>
)
}
export type ChainOfThoughtStepProps = {
children: React.ReactNode
className?: string
isLast?: boolean
}
export const ChainOfThoughtStep = ({
children,
className,
isLast = false,
...props
}: ChainOfThoughtStepProps & React.ComponentProps<typeof Collapsible>) => {
return (
<Collapsible
className={cn("group", className)}
data-last={isLast}
{...props}
>
{children}
<div className="flex justify-start group-data-[last=true]:hidden">
<div className="bg-primary/20 ml-1.75 h-4 w-px" />
</div>
</Collapsible>
)
}

View file

@ -0,0 +1,203 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { Brain, CheckCircle2, Loader2, Search, Sparkles } from "lucide-react";
import { useMemo } from "react";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtItem,
ChainOfThoughtStep,
ChainOfThoughtTrigger,
} from "@/components/prompt-kit/chain-of-thought";
import { cn } from "@/lib/utils";
/**
* Types for the deepagent thinking/reasoning tool
*/
interface ThinkingStep {
id: string;
title: string;
items: string[];
status: "pending" | "in_progress" | "completed";
}
interface DeepAgentThinkingArgs {
query?: string;
context?: string;
}
interface DeepAgentThinkingResult {
steps?: ThinkingStep[];
status?: "thinking" | "searching" | "synthesizing" | "completed";
summary?: string;
}
/**
* Get icon based on step status and type
*/
function getStepIcon(status: "pending" | "in_progress" | "completed", title: string) {
// Check for specific step types based on title keywords
const titleLower = title.toLowerCase();
if (status === "in_progress") {
return <Loader2 className="size-4 animate-spin text-primary" />;
}
if (status === "completed") {
return <CheckCircle2 className="size-4 text-emerald-500" />;
}
// Default icons based on step type
if (titleLower.includes("search") || titleLower.includes("knowledge")) {
return <Search className="size-4 text-muted-foreground" />;
}
if (titleLower.includes("analy") || titleLower.includes("understand")) {
return <Brain className="size-4 text-muted-foreground" />;
}
return <Sparkles className="size-4 text-muted-foreground" />;
}
/**
* Component to display a single thinking step
*/
function ThinkingStepDisplay({ step }: { step: ThinkingStep }) {
const icon = useMemo(() => getStepIcon(step.status, step.title), [step.status, step.title]);
return (
<ChainOfThoughtStep defaultOpen={step.status === "in_progress"}>
<ChainOfThoughtTrigger
leftIcon={icon}
swapIconOnHover={step.status !== "in_progress"}
className={cn(
step.status === "in_progress" && "text-foreground font-medium",
step.status === "completed" && "text-muted-foreground"
)}
>
{step.title}
</ChainOfThoughtTrigger>
<ChainOfThoughtContent>
{step.items.map((item, index) => (
<ChainOfThoughtItem key={`${step.id}-item-${index}`}>
{item}
</ChainOfThoughtItem>
))}
</ChainOfThoughtContent>
</ChainOfThoughtStep>
);
}
/**
* Loading state with animated thinking indicator
*/
function ThinkingLoadingState({ status }: { status?: string }) {
const statusText = useMemo(() => {
switch (status) {
case "searching":
return "Searching knowledge base...";
case "synthesizing":
return "Synthesizing response...";
case "thinking":
default:
return "Thinking...";
}
}, [status]);
return (
<div className="my-3 flex items-center gap-2 rounded-lg border border-border/50 bg-muted/30 px-4 py-3">
<div className="relative">
<Brain className="size-5 text-primary" />
<span className="absolute -right-0.5 -top-0.5 flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
<span className="relative inline-flex size-2 rounded-full bg-primary" />
</span>
</div>
<span className="text-sm text-muted-foreground">{statusText}</span>
</div>
);
}
/**
* DeepAgent Thinking Tool UI Component
*
* This component displays the agent's chain-of-thought reasoning
* when the deepagent is processing a query. It shows thinking steps
* in a collapsible, hierarchical format.
*/
export const DeepAgentThinkingToolUI = makeAssistantToolUI<
DeepAgentThinkingArgs,
DeepAgentThinkingResult
>({
toolName: "deepagent_thinking",
render: function DeepAgentThinkingUI({ args, result, status }) {
// Loading state - tool is still running
if (status.type === "running" || status.type === "requires-action") {
return <ThinkingLoadingState status={result?.status} />;
}
// Incomplete/cancelled state
if (status.type === "incomplete") {
if (status.reason === "cancelled") {
return null; // Don't show anything if cancelled
}
if (status.reason === "error") {
return null; // Don't show error for thinking - it's not critical
}
}
// No result or no steps - don't render anything
if (!result?.steps || result.steps.length === 0) {
return null;
}
// Render the chain of thought
return (
<div className="my-3 w-full">
<ChainOfThought>
{result.steps.map((step) => (
<ThinkingStepDisplay key={step.id} step={step} />
))}
</ChainOfThought>
</div>
);
},
});
/**
* Inline Thinking Display Component
*
* A simpler version that can be used inline with the message content
* for displaying reasoning without the full tool UI infrastructure.
*/
export function InlineThinkingDisplay({
steps,
isStreaming = false,
className,
}: {
steps: ThinkingStep[];
isStreaming?: boolean;
className?: string;
}) {
if (steps.length === 0 && !isStreaming) {
return null;
}
return (
<div className={cn("my-3 w-full", className)}>
{isStreaming && steps.length === 0 ? (
<ThinkingLoadingState />
) : (
<ChainOfThought>
{steps.map((step) => (
<ThinkingStepDisplay key={step.id} step={step} />
))}
</ChainOfThought>
)}
</div>
);
}
export type { ThinkingStep, DeepAgentThinkingArgs, DeepAgentThinkingResult };

View file

@ -8,3 +8,10 @@
export { Audio } from "./audio";
export { GeneratePodcastToolUI } from "./generate-podcast";
export {
DeepAgentThinkingToolUI,
InlineThinkingDisplay,
type ThinkingStep,
type DeepAgentThinkingArgs,
type DeepAgentThinkingResult,
} from "./deepagent-thinking";

View file

@ -1,21 +1,33 @@
"use client";
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
export { Collapsible, CollapsibleTrigger, CollapsibleContent }