feat: enhance chain-of-thought display with smart expand/collapse behavior and state management for improved user interaction

This commit is contained in:
Anish Sarkar 2025-12-23 02:21:41 +05:30
parent 24dd52ed99
commit 7ca490c740
3 changed files with 178 additions and 45 deletions

View file

@ -217,24 +217,11 @@ async def stream_new_chat(
yield completion_event yield completion_event
if just_finished_tool: if just_finished_tool:
# We just finished a tool - don't create a step here, # Clear the active step tracking - text flows without a dedicated step
# text will flow silently after tools.
# Clear the active step tracking.
last_active_step_id = None last_active_step_id = None
last_active_step_title = "" last_active_step_title = ""
last_active_step_items = [] last_active_step_items = []
just_finished_tool = False just_finished_tool = False
else:
# Normal text generation (not after a tool)
gen_step_id = next_thinking_step_id()
last_active_step_id = gen_step_id
last_active_step_title = "Generating response"
last_active_step_items = []
yield streaming_service.format_thinking_step(
step_id=gen_step_id,
title="Generating response",
status="in_progress",
)
current_text_id = streaming_service.generate_text_id() current_text_id = streaming_service.generate_text_id()
yield streaming_service.format_text_start(current_text_id) yield streaming_service.format_text_start(current_text_id)

View file

@ -30,7 +30,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { type FC, useState, useRef, useCallback } from "react"; import { type FC, useState, useRef, useCallback, useEffect } from "react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
@ -94,35 +94,102 @@ function getStepIcon(status: "pending" | "in_progress" | "completed", title: str
} }
/** /**
* Chain of thought display component * Chain of thought display component with smart expand/collapse behavior
*/ */
const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[] }> = ({ steps }) => { const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolean }> = ({ steps, isThreadRunning = true }) => {
// Track which steps the user has manually toggled (overrides auto behavior)
const [manualOverrides, setManualOverrides] = useState<Record<string, boolean>>({});
// Track previous step statuses to detect changes
const prevStatusesRef = useRef<Record<string, string>>({});
// Derive effective status: if thread stopped and step is in_progress, treat as completed
const getEffectiveStatus = (step: ThinkingStep): "pending" | "in_progress" | "completed" => {
if (step.status === "in_progress" && !isThreadRunning) {
return "completed"; // Thread was stopped, so mark as completed
}
return step.status;
};
// Check if any step is effectively in progress
const hasInProgressStep = steps.some(step => getEffectiveStatus(step) === "in_progress");
// Find the last completed step index (using effective status)
const lastCompletedIndex = steps
.map((s, i) => getEffectiveStatus(s) === "completed" ? i : -1)
.filter(i => i !== -1)
.pop();
// Clear manual overrides when a step's status changes
useEffect(() => {
const currentStatuses: Record<string, string> = {};
steps.forEach(step => {
currentStatuses[step.id] = step.status;
// If status changed, clear any manual override for this step
if (prevStatusesRef.current[step.id] && prevStatusesRef.current[step.id] !== step.status) {
setManualOverrides(prev => {
const next = { ...prev };
delete next[step.id];
return next;
});
}
});
prevStatusesRef.current = currentStatuses;
}, [steps]);
if (steps.length === 0) return null; if (steps.length === 0) return null;
const getStepOpenState = (step: ThinkingStep, index: number): boolean => {
const effectiveStatus = getEffectiveStatus(step);
// If user has manually toggled, respect that
if (manualOverrides[step.id] !== undefined) {
return manualOverrides[step.id];
}
// Auto behavior: open if in progress
if (effectiveStatus === "in_progress") {
return true;
}
// Auto behavior: keep last completed step open if no in-progress step
if (!hasInProgressStep && index === lastCompletedIndex) {
return true;
}
// Default: collapsed
return false;
};
const handleToggle = (stepId: string, currentOpen: boolean) => {
setManualOverrides(prev => ({
...prev,
[stepId]: !currentOpen,
}));
};
return ( return (
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2"> <div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
<ChainOfThought> <ChainOfThought>
{steps.map((step) => { {steps.map((step, index) => {
const icon = getStepIcon(step.status, step.title); const effectiveStatus = getEffectiveStatus(step);
const icon = getStepIcon(effectiveStatus, step.title);
const isOpen = getStepOpenState(step, index);
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
key={step.id} key={step.id}
defaultOpen={step.status === "in_progress"} open={isOpen}
onOpenChange={() => handleToggle(step.id, isOpen)}
> >
<ChainOfThoughtTrigger <ChainOfThoughtTrigger
leftIcon={icon} leftIcon={icon}
swapIconOnHover={step.status !== "in_progress"} swapIconOnHover={effectiveStatus !== "in_progress"}
className={cn( className={cn(
step.status === "in_progress" && "text-foreground font-medium", effectiveStatus === "in_progress" && "text-foreground font-medium",
step.status === "completed" && "text-muted-foreground" effectiveStatus === "completed" && "text-muted-foreground"
)} )}
> >
{step.title} {step.title}
</ChainOfThoughtTrigger> </ChainOfThoughtTrigger>
{step.items && step.items.length > 0 && ( {step.items && step.items.length > 0 && (
<ChainOfThoughtContent> <ChainOfThoughtContent>
{step.items.map((item, index) => ( {step.items.map((item, idx) => (
<ChainOfThoughtItem key={`${step.id}-item-${index}`}> <ChainOfThoughtItem key={`${step.id}-item-${idx}`}>
{item} {item}
</ChainOfThoughtItem> </ChainOfThoughtItem>
))} ))}
@ -263,8 +330,8 @@ const ThreadWelcome: FC = () => {
return ( return (
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative"> <div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
{/* Greeting positioned near the composer */} {/* Greeting positioned above the composer - fixed position */}
<div className="aui-thread-welcome-message absolute top-1/2 left-0 right-0 flex flex-col items-center text-center z-10 -translate-y-[calc(50%+100px)]"> <div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center z-10">
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-2 animate-in text-5xl delay-100 duration-500 ease-out fill-mode-both flex items-center gap-4"> <h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-2 animate-in text-5xl delay-100 duration-500 ease-out fill-mode-both flex items-center gap-4">
{/** biome-ignore lint/a11y/noStaticElementInteractions: wrong lint error, this is a workaround to fix the lint error */} {/** biome-ignore lint/a11y/noStaticElementInteractions: wrong lint error, this is a workaround to fix the lint error */}
<div <div
@ -295,8 +362,8 @@ const ThreadWelcome: FC = () => {
{getTimeBasedGreeting(user?.email)} {getTimeBasedGreeting(user?.email)}
</h1> </h1>
</div> </div>
{/* Composer centered in the middle of the screen */} {/* Composer - top edge fixed, expands downward only */}
<div className="fade-in slide-in-from-bottom-3 animate-in delay-200 duration-500 ease-out fill-mode-both w-full flex items-center justify-center absolute top-1/2 left-0 right-0 -translate-y-1/2"> <div className="fade-in slide-in-from-bottom-3 animate-in delay-200 duration-500 ease-out fill-mode-both w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
<Composer /> <Composer />
</div> </div>
</div> </div>
@ -525,12 +592,15 @@ const AssistantMessageInner: FC = () => {
const messageId = useMessage((m) => m.id); const messageId = useMessage((m) => m.id);
const thinkingSteps = thinkingStepsMap.get(messageId) || []; const thinkingSteps = thinkingStepsMap.get(messageId) || [];
// Check if thread is still running (for stopping the spinner when cancelled)
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
return ( return (
<> <>
{/* Show thinking steps BEFORE the text response */} {/* Show thinking steps BEFORE the text response */}
{thinkingSteps.length > 0 && ( {thinkingSteps.length > 0 && (
<div className="mb-3"> <div className="mb-3">
<ThinkingStepsDisplay steps={thinkingSteps} /> <ThinkingStepsDisplay steps={thinkingSteps} isThreadRunning={isThreadRunning} />
</div> </div>
)} )}

View file

@ -2,7 +2,7 @@
import { makeAssistantToolUI } from "@assistant-ui/react"; import { makeAssistantToolUI } from "@assistant-ui/react";
import { Brain, CheckCircle2, Loader2, Search, Sparkles } from "lucide-react"; import { Brain, CheckCircle2, Loader2, Search, Sparkles } from "lucide-react";
import { useMemo } from "react"; import { useMemo, useState, useEffect, useRef } from "react";
import { import {
ChainOfThought, ChainOfThought,
ChainOfThoughtContent, ChainOfThoughtContent,
@ -61,13 +61,21 @@ function getStepIcon(status: "pending" | "in_progress" | "completed", title: str
} }
/** /**
* Component to display a single thinking step * Component to display a single thinking step with controlled open state
*/ */
function ThinkingStepDisplay({ step }: { step: ThinkingStep }) { function ThinkingStepDisplay({
step,
isOpen,
onToggle
}: {
step: ThinkingStep;
isOpen: boolean;
onToggle: () => void;
}) {
const icon = useMemo(() => getStepIcon(step.status, step.title), [step.status, step.title]); const icon = useMemo(() => getStepIcon(step.status, step.title), [step.status, step.title]);
return ( return (
<ChainOfThoughtStep defaultOpen={step.status === "in_progress"}> <ChainOfThoughtStep open={isOpen} onOpenChange={onToggle}>
<ChainOfThoughtTrigger <ChainOfThoughtTrigger
leftIcon={icon} leftIcon={icon}
swapIconOnHover={step.status !== "in_progress"} swapIconOnHover={step.status !== "in_progress"}
@ -119,6 +127,82 @@ function ThinkingLoadingState({ status }: { status?: string }) {
); );
} }
/**
* Smart chain of thought renderer with state management
*/
function SmartChainOfThought({ steps }: { steps: ThinkingStep[] }) {
// Track which steps the user has manually toggled
const [manualOverrides, setManualOverrides] = useState<Record<string, boolean>>({});
// Track previous step statuses to detect changes
const prevStatusesRef = useRef<Record<string, string>>({});
// Check if any step is currently in progress
const hasInProgressStep = steps.some(step => step.status === "in_progress");
// Find the last completed step index
const lastCompletedIndex = steps
.map((s, i) => s.status === "completed" ? i : -1)
.filter(i => i !== -1)
.pop();
// Clear manual overrides when a step's status changes
useEffect(() => {
const currentStatuses: Record<string, string> = {};
steps.forEach(step => {
currentStatuses[step.id] = step.status;
// If status changed, clear any manual override for this step
if (prevStatusesRef.current[step.id] && prevStatusesRef.current[step.id] !== step.status) {
setManualOverrides(prev => {
const next = { ...prev };
delete next[step.id];
return next;
});
}
});
prevStatusesRef.current = currentStatuses;
}, [steps]);
const getStepOpenState = (step: ThinkingStep, index: number): boolean => {
// If user has manually toggled, respect that
if (manualOverrides[step.id] !== undefined) {
return manualOverrides[step.id];
}
// Auto behavior: open if in progress
if (step.status === "in_progress") {
return true;
}
// Auto behavior: keep last completed step open if no in-progress step
if (!hasInProgressStep && index === lastCompletedIndex) {
return true;
}
// Default: collapsed
return false;
};
const handleToggle = (stepId: string, currentOpen: boolean) => {
setManualOverrides(prev => ({
...prev,
[stepId]: !currentOpen,
}));
};
return (
<ChainOfThought>
{steps.map((step, index) => {
const isOpen = getStepOpenState(step, index);
return (
<ThinkingStepDisplay
key={step.id}
step={step}
isOpen={isOpen}
onToggle={() => handleToggle(step.id, isOpen)}
/>
);
})}
</ChainOfThought>
);
}
/** /**
* DeepAgent Thinking Tool UI Component * DeepAgent Thinking Tool UI Component
* *
@ -131,7 +215,7 @@ export const DeepAgentThinkingToolUI = makeAssistantToolUI<
DeepAgentThinkingResult DeepAgentThinkingResult
>({ >({
toolName: "deepagent_thinking", toolName: "deepagent_thinking",
render: function DeepAgentThinkingUI({ args, result, status }) { render: function DeepAgentThinkingUI({ result, status }) {
// Loading state - tool is still running // Loading state - tool is still running
if (status.type === "running" || status.type === "requires-action") { if (status.type === "running" || status.type === "requires-action") {
return <ThinkingLoadingState status={result?.status} />; return <ThinkingLoadingState status={result?.status} />;
@ -155,11 +239,7 @@ export const DeepAgentThinkingToolUI = makeAssistantToolUI<
// Render the chain of thought // Render the chain of thought
return ( return (
<div className="my-3 w-full"> <div className="my-3 w-full">
<ChainOfThought> <SmartChainOfThought steps={result.steps} />
{result.steps.map((step) => (
<ThinkingStepDisplay key={step.id} step={step} />
))}
</ChainOfThought>
</div> </div>
); );
}, },
@ -189,11 +269,7 @@ export function InlineThinkingDisplay({
{isStreaming && steps.length === 0 ? ( {isStreaming && steps.length === 0 ? (
<ThinkingLoadingState /> <ThinkingLoadingState />
) : ( ) : (
<ChainOfThought> <SmartChainOfThought steps={steps} />
{steps.map((step) => (
<ThinkingStepDisplay key={step.id} step={step} />
))}
</ChainOfThought>
)} )}
</div> </div>
); );