diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx
new file mode 100644
index 000000000..62fbe0dd4
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/assistant-message.tsx
@@ -0,0 +1,118 @@
+import {
+ ActionBarPrimitive,
+ AssistantIf,
+ ErrorPrimitive,
+ MessagePrimitive,
+ useAssistantState,
+} from "@assistant-ui/react";
+import { CheckIcon, CopyIcon, DownloadIcon, RefreshCwIcon } from "lucide-react";
+import type { FC } from "react";
+import { useContext } from "react";
+import { MarkdownText } from "@/components/assistant-ui/markdown-text";
+import { ThinkingStepsContext, ThinkingStepsDisplay } from "@/components/assistant-ui/thinking-steps";
+import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
+import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
+import { BranchPicker } from "@/components/assistant-ui/branch-picker";
+
+export const MessageError: FC = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+/**
+ * Custom component to render thinking steps from Context
+ */
+const ThinkingStepsPart: FC = () => {
+ const thinkingStepsMap = useContext(ThinkingStepsContext);
+
+ // Get the current message ID to look up thinking steps
+ const messageId = useAssistantState(({ message }) => message?.id);
+ const thinkingSteps = thinkingStepsMap.get(messageId) || [];
+
+ // Check if this specific message is currently streaming
+ // A message is streaming if: thread is running AND this is the last assistant message
+ const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
+ const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
+ const isMessageStreaming = isThreadRunning && isLastMessage;
+
+ if (thinkingSteps.length === 0) return null;
+
+ return (
+
+
+
+ );
+};
+
+const AssistantMessageInner: FC = () => {
+ return (
+ <>
+ {/* Render thinking steps from message content - this ensures proper scroll tracking */}
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export const AssistantMessage: FC = () => {
+ return (
+
+
+
+ );
+};
+
+const AssistantActionBar: FC = () => {
+ return (
+
+
+
+ message.isCopied}>
+
+
+ !message.isCopied}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
diff --git a/surfsense_web/components/assistant-ui/branch-picker.tsx b/surfsense_web/components/assistant-ui/branch-picker.tsx
new file mode 100644
index 000000000..1d9041309
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/branch-picker.tsx
@@ -0,0 +1,33 @@
+import { BranchPickerPrimitive } from "@assistant-ui/react";
+import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
+import type { FC } from "react";
+import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
+import { cn } from "@/lib/utils";
+
+export const BranchPicker: FC = ({ className, ...rest }) => {
+ return (
+
+
+
+
+
+
+
+ /
+
+
+
+
+
+
+
+ );
+};
+
diff --git a/surfsense_web/components/assistant-ui/composer-action.tsx b/surfsense_web/components/assistant-ui/composer-action.tsx
new file mode 100644
index 000000000..9c5a95d88
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/composer-action.tsx
@@ -0,0 +1,269 @@
+import { AssistantIf, ComposerPrimitive, useAssistantState } from "@assistant-ui/react";
+import { useAtomValue } from "jotai";
+import { AlertCircle, ArrowUpIcon, Loader2, Plus, Plug2, SquareIcon } from "lucide-react";
+import Link from "next/link";
+import type { FC } from "react";
+import { useCallback, useMemo, useRef, useState } from "react";
+import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
+import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
+import {
+ globalNewLLMConfigsAtom,
+ llmPreferencesAtom,
+ newLLMConfigsAtom,
+} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
+import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
+import { ComposerAddAttachment } from "@/components/assistant-ui/attachment";
+import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
+import { Button } from "@/components/ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
+import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
+import { cn } from "@/lib/utils";
+import { ChevronRightIcon } from "lucide-react";
+
+const ConnectorIndicator: FC = () => {
+ const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
+ const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors(
+ false,
+ searchSpaceId ? Number(searchSpaceId) : undefined
+ );
+ const { data: documentTypeCounts, isLoading: documentTypesLoading } =
+ useAtomValue(documentTypeCountsAtom);
+ const [isOpen, setIsOpen] = useState(false);
+ const closeTimeoutRef = useRef(null);
+
+ const isLoading = connectorsLoading || documentTypesLoading;
+
+ // Get document types that have documents in the search space
+ const activeDocumentTypes = documentTypeCounts
+ ? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0)
+ : [];
+
+ const hasConnectors = connectors.length > 0;
+ const hasSources = hasConnectors || activeDocumentTypes.length > 0;
+ const totalSourceCount = connectors.length + activeDocumentTypes.length;
+
+ const handleMouseEnter = useCallback(() => {
+ // Clear any pending close timeout
+ if (closeTimeoutRef.current) {
+ clearTimeout(closeTimeoutRef.current);
+ closeTimeoutRef.current = null;
+ }
+ setIsOpen(true);
+ }, []);
+
+ const handleMouseLeave = useCallback(() => {
+ // Delay closing by 150ms for better UX
+ closeTimeoutRef.current = setTimeout(() => {
+ setIsOpen(false);
+ }, 150);
+ }, []);
+
+ if (!searchSpaceId) return null;
+
+ return (
+
+
+
+
+
+ {hasSources ? (
+
+
+
Connected Sources
+
+ {totalSourceCount}
+
+
+
+ {/* Document types from the search space */}
+ {activeDocumentTypes.map(([docType]) => (
+
+ {getConnectorIcon(docType, "size-3.5")}
+ {getDocumentTypeLabel(docType)}
+
+ ))}
+ {/* Search source connectors */}
+ {connectors.map((connector) => (
+
+ {getConnectorIcon(connector.connector_type, "size-3.5")}
+ {connector.name}
+
+ ))}
+
+
+
+
+ Add more sources
+
+
+
+
+ ) : (
+
+
No sources yet
+
+ Add documents or connect data sources to enhance search results.
+
+
+
+ Add Connector
+
+
+ )}
+
+
+ );
+};
+
+export const ComposerAction: FC = () => {
+ // Check if any attachments are still being processed (running AND progress < 100)
+ // When progress is 100, processing is done but waiting for send()
+ const hasProcessingAttachments = useAssistantState(({ composer }) =>
+ composer.attachments?.some((att) => {
+ const status = att.status;
+ if (status?.type !== "running") return false;
+ const progress = (status as { type: "running"; progress?: number }).progress;
+ return progress === undefined || progress < 100;
+ })
+ );
+
+ // Check if composer text is empty
+ const isComposerEmpty = useAssistantState(({ composer }) => {
+ const text = composer.text?.trim() || "";
+ return text.length === 0;
+ });
+
+ // Check if a model is configured
+ const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
+ const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
+ const { data: preferences } = useAtomValue(llmPreferencesAtom);
+
+ const hasModelConfigured = useMemo(() => {
+ if (!preferences) return false;
+ const agentLlmId = preferences.agent_llm_id;
+ if (agentLlmId === null || agentLlmId === undefined) return false;
+
+ // Check if the configured model actually exists
+ if (agentLlmId < 0) {
+ return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
+ }
+ return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
+ }, [preferences, globalConfigs, userConfigs]);
+
+ const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured;
+
+ return (
+
+
+
+
+
+
+ {/* Show processing indicator when attachments are being processed */}
+ {hasProcessingAttachments && (
+
+
+ Processing...
+
+ )}
+
+ {/* Show warning when no model is configured */}
+ {!hasModelConfigured && !hasProcessingAttachments && (
+
+ )}
+
+
!thread.isRunning}>
+
+
+
+
+
+
+
+
thread.isRunning}>
+
+
+
+
+
+ );
+};
+
diff --git a/surfsense_web/components/assistant-ui/composer.tsx b/surfsense_web/components/assistant-ui/composer.tsx
new file mode 100644
index 000000000..1973726da
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/composer.tsx
@@ -0,0 +1,240 @@
+import { ComposerPrimitive, useAssistantState, useComposerRuntime } from "@assistant-ui/react";
+import { useAtom, useSetAtom } from "jotai";
+import { useParams } from "next/navigation";
+import type { FC } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import {
+ mentionedDocumentIdsAtom,
+ mentionedDocumentsAtom,
+} from "@/atoms/chat/mentioned-documents.atom";
+import {
+ ComposerAddAttachment,
+ ComposerAttachments,
+} from "@/components/assistant-ui/attachment";
+import { ComposerAction } from "@/components/assistant-ui/composer-action";
+import {
+ InlineMentionEditor,
+ type InlineMentionEditorRef,
+} from "@/components/assistant-ui/inline-mention-editor";
+import {
+ DocumentMentionPicker,
+ type DocumentMentionPickerRef,
+} from "@/components/new-chat/document-mention-picker";
+import type { Document } from "@/contracts/types/document.types";
+
+export const Composer: FC = () => {
+ // ---- State for document mentions (using atoms to persist across remounts) ----
+ const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
+ const [showDocumentPopover, setShowDocumentPopover] = useState(false);
+ const [mentionQuery, setMentionQuery] = useState("");
+ const editorRef = useRef(null);
+ const editorContainerRef = useRef(null);
+ const documentPickerRef = useRef(null);
+ const { search_space_id } = useParams();
+ const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
+ const composerRuntime = useComposerRuntime();
+ const hasAutoFocusedRef = useRef(false);
+
+ // Check if thread is empty (new chat)
+ const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
+
+ // Check if thread is currently running (streaming response)
+ const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
+
+ // Auto-focus editor when on new chat page
+ useEffect(() => {
+ if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
+ // Small delay to ensure the editor is fully mounted
+ const timeoutId = setTimeout(() => {
+ editorRef.current?.focus();
+ hasAutoFocusedRef.current = true;
+ }, 100);
+ return () => clearTimeout(timeoutId);
+ }
+ }, [isThreadEmpty]);
+
+ // Sync mentioned document IDs to atom for use in chat request
+ useEffect(() => {
+ setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id));
+ }, [mentionedDocuments, setMentionedDocumentIds]);
+
+ // Handle text change from inline editor - sync with assistant-ui composer
+ const handleEditorChange = useCallback(
+ (text: string) => {
+ composerRuntime.setText(text);
+ },
+ [composerRuntime]
+ );
+
+ // Handle @ mention trigger from inline editor
+ const handleMentionTrigger = useCallback((query: string) => {
+ setShowDocumentPopover(true);
+ setMentionQuery(query);
+ }, []);
+
+ // Handle mention close
+ const handleMentionClose = useCallback(() => {
+ if (showDocumentPopover) {
+ setShowDocumentPopover(false);
+ setMentionQuery("");
+ }
+ }, [showDocumentPopover]);
+
+ // Handle keyboard navigation when popover is open
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (showDocumentPopover) {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ documentPickerRef.current?.moveDown();
+ return;
+ }
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ documentPickerRef.current?.moveUp();
+ return;
+ }
+ if (e.key === "Enter") {
+ e.preventDefault();
+ documentPickerRef.current?.selectHighlighted();
+ return;
+ }
+ if (e.key === "Escape") {
+ e.preventDefault();
+ setShowDocumentPopover(false);
+ setMentionQuery("");
+ return;
+ }
+ }
+ },
+ [showDocumentPopover]
+ );
+
+ // Handle submit from inline editor (Enter key)
+ const handleSubmit = useCallback(() => {
+ // Prevent sending while a response is still streaming
+ if (isThreadRunning) {
+ return;
+ }
+ if (!showDocumentPopover) {
+ composerRuntime.send();
+ // Clear the editor after sending
+ editorRef.current?.clear();
+ setMentionedDocuments([]);
+ setMentionedDocumentIds([]);
+ }
+ }, [
+ showDocumentPopover,
+ isThreadRunning,
+ composerRuntime,
+ setMentionedDocuments,
+ setMentionedDocumentIds,
+ ]);
+
+ // Handle document removal from inline editor
+ const handleDocumentRemove = useCallback(
+ (docId: number) => {
+ setMentionedDocuments((prev) => {
+ const updated = prev.filter((doc) => doc.id !== docId);
+ // Immediately sync document IDs to avoid race conditions
+ setMentionedDocumentIds(updated.map((doc) => doc.id));
+ return updated;
+ });
+ },
+ [setMentionedDocuments, setMentionedDocumentIds]
+ );
+
+ // Handle document selection from picker
+ const handleDocumentsMention = useCallback(
+ (documents: Document[]) => {
+ // Insert chips into the inline editor for each new document
+ const existingIds = new Set(mentionedDocuments.map((d) => d.id));
+ const newDocs = documents.filter((doc) => !existingIds.has(doc.id));
+
+ for (const doc of newDocs) {
+ editorRef.current?.insertDocumentChip(doc);
+ }
+
+ // Update mentioned documents state
+ setMentionedDocuments((prev) => {
+ const existingIdSet = new Set(prev.map((d) => d.id));
+ const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id));
+ const updated = [...prev, ...uniqueNewDocs];
+ // Immediately sync document IDs to avoid race conditions
+ setMentionedDocumentIds(updated.map((doc) => doc.id));
+ return updated;
+ });
+
+ // Reset mention query but keep popover open for more selections
+ setMentionQuery("");
+ },
+ [mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
+ );
+
+ return (
+
+
+
+ {/* -------- Inline Mention Editor -------- */}
+
+
+
+
+ {/* -------- Document mention popover (rendered via portal) -------- */}
+ {showDocumentPopover &&
+ typeof document !== "undefined" &&
+ createPortal(
+ <>
+ {/* Backdrop */}
+
+
+ );
+};
+
diff --git a/surfsense_web/components/assistant-ui/edit-composer.tsx b/surfsense_web/components/assistant-ui/edit-composer.tsx
new file mode 100644
index 000000000..4e6346909
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/edit-composer.tsx
@@ -0,0 +1,27 @@
+import { ComposerPrimitive, MessagePrimitive } from "@assistant-ui/react";
+import type { FC } from "react";
+import { Button } from "@/components/ui/button";
+
+export const EditComposer: FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
diff --git a/surfsense_web/components/assistant-ui/thinking-steps.tsx b/surfsense_web/components/assistant-ui/thinking-steps.tsx
new file mode 100644
index 000000000..f0cf4a7c1
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/thinking-steps.tsx
@@ -0,0 +1,207 @@
+import { useAssistantState, useThreadViewport } from "@assistant-ui/react";
+import type { FC } from "react";
+import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
+import { ChevronRightIcon } from "lucide-react";
+import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought";
+import { TextShimmerLoader } from "@/components/prompt-kit/loader";
+import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
+import { cn } from "@/lib/utils";
+
+// Context to pass thinking steps to AssistantMessage
+export const ThinkingStepsContext = createContext