diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 1d4ebed6d..d2b82000f 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -147,15 +147,6 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea 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 = {}; @@ -175,7 +166,7 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea if (steps.length === 0) return null; - const getStepOpenState = (step: ThinkingStep, index: number): boolean => { + const getStepOpenState = (step: ThinkingStep): boolean => { const effectiveStatus = getEffectiveStatus(step); // If user has manually toggled, respect that if (manualOverrides[step.id] !== undefined) { @@ -185,11 +176,7 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea 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 + // Default: collapsed (all steps collapse when processing is done) return false; }; @@ -203,10 +190,10 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea return (
- {steps.map((step, index) => { + {steps.map((step) => { const effectiveStatus = getEffectiveStatus(step); const icon = getStepIcon(effectiveStatus, step.title); - const isOpen = getStepOpenState(step, index); + const isOpen = getStepOpenState(step); return ( { @@ -30,44 +62,60 @@ function useEntranceAnimation(delay = 0) { return isVisible; } +// ============================================================================ +// File Icon Utilities +// ============================================================================ + +/** + * Check if an extension belongs to a specific category + */ +function isExtensionInCategory( + ext: string, + category: FileExtensionCategory +): boolean { + return (FILE_EXTENSIONS[category] as readonly string[]).includes(ext); +} + /** * Get file icon based on file extension (all icons are muted/gray) */ function getFileIcon(name: string): React.ReactNode { - const ext = name.split(".").pop()?.toLowerCase() || ""; + const ext = name.split(".").pop()?.toLowerCase() ?? ""; - // PDF / Word documents - if (ext === "pdf" || ["doc", "docx"].includes(ext)) { - return ; + if (isExtensionInCategory(ext, "DOCUMENT")) { + return ; } - // Spreadsheets - if (["xls", "xlsx", "csv"].includes(ext)) { - return ; + if (isExtensionInCategory(ext, "SPREADSHEET")) { + return ; } - // Images - if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) { - return ; + if (isExtensionInCategory(ext, "IMAGE")) { + return ; } - // Audio - if (["mp3", "wav", "m4a", "ogg", "webm"].includes(ext)) { - return ; + if (isExtensionInCategory(ext, "AUDIO")) { + return ; } - // Video - if (["mp4", "mov", "avi", "mkv"].includes(ext)) { - return ; + if (isExtensionInCategory(ext, "VIDEO")) { + return ; } - // Code files - if (["js", "ts", "tsx", "jsx", "py", "html", "css", "json", "md"].includes(ext)) { - return ; + if (isExtensionInCategory(ext, "CODE")) { + return ; } - // Default - return ; + return ; +} + +// ============================================================================ +// Attachment Components +// ============================================================================ + +interface AttachmentTileProps { + /** File name to display */ + name: string; } /** * Compact attachment tile component - matches the chat UI style */ -const AttachmentTile: React.FC<{ name: string }> = ({ name }) => { +const AttachmentTile: React.FC = ({ name }) => { const icon = getFileIcon(name); return ( @@ -120,26 +168,46 @@ function parseAndRenderWithBadges(text: string): React.ReactNode { return parts; } -export type ChainOfThoughtItemProps = React.ComponentProps<"div">; +// ============================================================================ +// Chain of Thought Components +// ============================================================================ -export const ChainOfThoughtItem = ({ children, className, ...props }: ChainOfThoughtItemProps) => ( -
+export interface ChainOfThoughtItemProps + extends React.HTMLAttributes { + children: React.ReactNode; +} + +export const ChainOfThoughtItem: React.FC = ({ + children, + className, + ...props +}) => ( +
{typeof children === "string" ? parseAndRenderWithBadges(children) : children}
); -export type ChainOfThoughtTriggerProps = React.ComponentProps & { +export interface ChainOfThoughtTriggerProps + extends React.ComponentProps { + /** Optional icon to display on the left side */ leftIcon?: React.ReactNode; + /** Whether to swap the icon with chevron on hover */ swapIconOnHover?: boolean; -}; +} -export const ChainOfThoughtTrigger = ({ +export const ChainOfThoughtTrigger: React.FC = ({ children, className, leftIcon, swapIconOnHover = true, ...props -}: ChainOfThoughtTriggerProps) => ( +}) => ( ); -export type ChainOfThoughtContentProps = React.ComponentProps; +export interface ChainOfThoughtContentProps + extends React.ComponentProps {} -export const ChainOfThoughtContent = ({ +export const ChainOfThoughtContent: React.FC = ({ children, className, ...props -}: ChainOfThoughtContentProps) => { +}) => { return ( {child}
@@ -213,12 +285,15 @@ export const ChainOfThoughtContent = ({ ); }; -export type ChainOfThoughtProps = { +export interface ChainOfThoughtProps { children: React.ReactNode; className?: string; -}; +} -export function ChainOfThought({ children, className }: ChainOfThoughtProps) { +export const ChainOfThought: React.FC = ({ + children, + className, +}) => { const childrenArray = React.Children.toArray(children); return ( @@ -238,25 +313,31 @@ export function ChainOfThought({ children, className }: ChainOfThoughtProps) { })}
); -} - -export type ChainOfThoughtStepProps = { - children: React.ReactNode; - className?: string; - isLast?: boolean; - /** Index of the step for staggered animation */ - stepIndex?: number; }; -export const ChainOfThoughtStep = ({ +export interface ChainOfThoughtStepProps + extends Omit, "children"> { + children: React.ReactNode; + className?: string; + /** Whether this is the last step (hides connection line) */ + isLast?: boolean; + /** Index of the step for staggered animation timing */ + stepIndex?: number; +} + +export const ChainOfThoughtStep: React.FC = ({ children, className, isLast = false, stepIndex = 0, ...props -}: ChainOfThoughtStepProps & React.ComponentProps) => { +}) => { // Staggered entrance animation based on step index - const isVisible = useEntranceAnimation(stepIndex * 50); + const isVisible = useEntranceAnimation(stepIndex * ANIMATION.STAGGER_DELAY_MS); + + // Calculate connection line delay: step delay + additional offset + const connectionLineDelay = + stepIndex * ANIMATION.STAGGER_DELAY_MS + ANIMATION.CONNECTION_LINE_DELAY_MS; return ( diff --git a/surfsense_web/components/tool-ui/deepagent-thinking.tsx b/surfsense_web/components/tool-ui/deepagent-thinking.tsx index 573837bce..22c4777f7 100644 --- a/surfsense_web/components/tool-ui/deepagent-thinking.tsx +++ b/surfsense_web/components/tool-ui/deepagent-thinking.tsx @@ -2,7 +2,8 @@ import { makeAssistantToolUI } from "@assistant-ui/react"; import { Brain, CheckCircle2, Loader2, Search, Sparkles } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import type { FC, ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { z } from "zod"; import { ChainOfThought, @@ -13,14 +14,59 @@ import { } from "@/components/prompt-kit/chain-of-thought"; import { cn } from "@/lib/utils"; -/** - * Zod schemas for runtime validation - */ +// ============================================================================ +// Constants +// ============================================================================ + +/** Step status values */ +const STEP_STATUS = { + PENDING: "pending", + IN_PROGRESS: "in_progress", + COMPLETED: "completed", +} as const; + +/** Agent thinking status values */ +const THINKING_STATUS = { + THINKING: "thinking", + SEARCHING: "searching", + SYNTHESIZING: "synthesizing", + COMPLETED: "completed", +} as const; + +/** Keywords for icon detection */ +const STEP_KEYWORDS = { + SEARCH: ["search", "knowledge"] as const, + ANALYSIS: ["analy", "understand"] as const, +} as const; + +/** Icon size class */ +const ICON_SIZE_CLASS = "size-4" as const; + +/** Status text mapping */ +const STATUS_TEXT_MAP: Record = { + [THINKING_STATUS.SEARCHING]: "Searching knowledge base...", + [THINKING_STATUS.SYNTHESIZING]: "Synthesizing response...", + [THINKING_STATUS.THINKING]: "Thinking...", +} as const; + +// ============================================================================ +// Type Definitions +// ============================================================================ + +type StepStatus = (typeof STEP_STATUS)[keyof typeof STEP_STATUS]; +type ThinkingStatus = (typeof THINKING_STATUS)[keyof typeof THINKING_STATUS]; + +// ============================================================================ +// Zod Schemas +// ============================================================================ + const ThinkingStepSchema = z.object({ id: z.string(), title: z.string(), items: z.array(z.string()).default([]), - status: z.enum(["pending", "in_progress", "completed"]).default("pending"), + status: z + .enum([STEP_STATUS.PENDING, STEP_STATUS.IN_PROGRESS, STEP_STATUS.COMPLETED]) + .default(STEP_STATUS.PENDING), }); const DeepAgentThinkingArgsSchema = z.object({ @@ -30,17 +76,34 @@ const DeepAgentThinkingArgsSchema = z.object({ const DeepAgentThinkingResultSchema = z.object({ steps: z.array(ThinkingStepSchema).optional(), - status: z.enum(["thinking", "searching", "synthesizing", "completed"]).optional(), + status: z + .enum([ + THINKING_STATUS.THINKING, + THINKING_STATUS.SEARCHING, + THINKING_STATUS.SYNTHESIZING, + THINKING_STATUS.COMPLETED, + ]) + .optional(), summary: z.string().optional(), }); -/** - * Types derived from Zod schemas - */ +/** Types derived from Zod schemas */ type ThinkingStep = z.infer; type DeepAgentThinkingArgs = z.infer; type DeepAgentThinkingResult = z.infer; +// ============================================================================ +// Parser Functions +// ============================================================================ + +/** Default fallback step when parsing fails */ +const DEFAULT_FALLBACK_STEP: ThinkingStep = { + id: "unknown", + title: "Processing...", + items: [], + status: STEP_STATUS.PENDING, +} as const; + /** * Parse and validate a single thinking step */ @@ -48,13 +111,7 @@ export function parseThinkingStep(data: unknown): ThinkingStep { const result = ThinkingStepSchema.safeParse(data); if (!result.success) { console.warn("Invalid thinking step data:", result.error.issues); - // Return a fallback step - return { - id: "unknown", - title: "Processing...", - items: [], - status: "pending", - }; + return DEFAULT_FALLBACK_STEP; } return result.data; } @@ -71,55 +128,79 @@ export function parseThinkingResult(data: unknown): DeepAgentThinkingResult { return result.data; } +// ============================================================================ +// Icon Utilities +// ============================================================================ + /** - * Get icon based on step status and type + * Check if title contains any of the keywords */ -function getStepIcon(status: "pending" | "in_progress" | "completed", title: string) { - // Check for specific step types based on title keywords +function titleContainsKeywords( + title: string, + keywords: readonly string[] +): boolean { const titleLower = title.toLowerCase(); + return keywords.some((keyword) => titleLower.includes(keyword)); +} - if (status === "in_progress") { - return ; +/** + * Get icon based on step status and title + */ +function getStepIcon(status: StepStatus, title: string): ReactNode { + if (status === STEP_STATUS.IN_PROGRESS) { + return ; } - if (status === "completed") { - return ; + if (status === STEP_STATUS.COMPLETED) { + return ; } - // Default icons based on step type - if (titleLower.includes("search") || titleLower.includes("knowledge")) { - return ; + // Default icons based on step type keywords + if (titleContainsKeywords(title, STEP_KEYWORDS.SEARCH)) { + return ; } - if (titleLower.includes("analy") || titleLower.includes("understand")) { - return ; + if (titleContainsKeywords(title, STEP_KEYWORDS.ANALYSIS)) { + return ; } - return ; + return ; +} + +// ============================================================================ +// Sub-Components +// ============================================================================ + +interface ThinkingStepDisplayProps { + step: ThinkingStep; + isOpen: boolean; + onToggle: () => void; } /** * Component to display a single thinking step with controlled open state */ -function ThinkingStepDisplay({ +const ThinkingStepDisplay: FC = ({ 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] + ); + + const isInProgress = step.status === STEP_STATUS.IN_PROGRESS; + const isCompleted = step.status === STEP_STATUS.COMPLETED; return ( {step.title} @@ -131,22 +212,21 @@ function ThinkingStepDisplay({ ); +}; + +interface ThinkingLoadingStateProps { + status?: ThinkingStatus | string; } /** * Loading state with animated thinking indicator */ -function ThinkingLoadingState({ status }: { status?: string }) { +const ThinkingLoadingState: FC = ({ status }) => { const statusText = useMemo(() => { - switch (status) { - case "searching": - return "Searching knowledge base..."; - case "synthesizing": - return "Synthesizing response..."; - case "thinking": - default: - return "Thinking..."; + if (status && status in STATUS_TEXT_MAP) { + return STATUS_TEXT_MAP[status]; } + return STATUS_TEXT_MAP[THINKING_STATUS.THINKING]; }, [status]); return ( @@ -161,33 +241,35 @@ function ThinkingLoadingState({ status }: { status?: string }) { {statusText} ); +}; + +interface SmartChainOfThoughtProps { + steps: ThinkingStep[]; } +/** Type for tracking step override states */ +type StepOverrides = Record; + +/** Type for tracking step status history */ +type StepStatusHistory = Record; + /** * Smart chain of thought renderer with state management */ -function SmartChainOfThought({ steps }: { steps: ThinkingStep[] }) { +const SmartChainOfThought: FC = ({ steps }) => { // Track which steps the user has manually toggled - const [manualOverrides, setManualOverrides] = useState>({}); + const [manualOverrides, setManualOverrides] = useState({}); // Track previous step statuses to detect changes - const prevStatusesRef = useRef>({}); - - // 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(); + const prevStatusesRef = useRef({}); // Clear manual overrides when a step's status changes useEffect(() => { - const currentStatuses: Record = {}; + const currentStatuses: StepStatusHistory = {}; 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) { + const prevStatus = prevStatusesRef.current[step.id]; + if (prevStatus && prevStatus !== step.status) { setManualOverrides((prev) => { const next = { ...prev }; delete next[step.id]; @@ -198,34 +280,33 @@ function SmartChainOfThought({ steps }: { steps: ThinkingStep[] }) { 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 getStepOpenState = useCallback( + (step: ThinkingStep): 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 === STEP_STATUS.IN_PROGRESS) { + return true; + } + // Default: collapsed (all steps collapse when processing is done) + return false; + }, + [manualOverrides] + ); - const handleToggle = (stepId: string, currentOpen: boolean) => { + const handleToggle = useCallback((stepId: string, currentOpen: boolean) => { setManualOverrides((prev) => ({ ...prev, [stepId]: !currentOpen, })); - }; + }, []); return ( - {steps.map((step, index) => { - const isOpen = getStepOpenState(step, index); + {steps.map((step) => { + const isOpen = getStepOpenState(step); return ( ); -} +}; /** * DeepAgent Thinking Tool UI Component @@ -281,21 +362,30 @@ export const DeepAgentThinkingToolUI = makeAssistantToolUI< }, }); +// ============================================================================ +// Public Components +// ============================================================================ + +export interface InlineThinkingDisplayProps { + /** The thinking steps to display */ + steps: ThinkingStep[]; + /** Whether content is currently streaming */ + isStreaming?: boolean; + /** Additional CSS class names */ + className?: string; +} + /** * 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({ +export const InlineThinkingDisplay: FC = ({ steps, isStreaming = false, className, -}: { - steps: ThinkingStep[]; - isStreaming?: boolean; - className?: string; -}) { +}) => { if (steps.length === 0 && !isStreaming) { return null; } @@ -309,6 +399,18 @@ export function InlineThinkingDisplay({ )} ); -} +}; -export type { ThinkingStep, DeepAgentThinkingArgs, DeepAgentThinkingResult }; +// ============================================================================ +// Exports +// ============================================================================ + +export type { + ThinkingStep, + DeepAgentThinkingArgs, + DeepAgentThinkingResult, + StepStatus, + ThinkingStatus, +}; + +export { STEP_STATUS, THINKING_STATUS };