From b5e20e7515b34a0a28d213b52da8fa2667bd92b6 Mon Sep 17 00:00:00 2001
From: "DESKTOP-RTLN3BA\\$punk"
Date: Sun, 21 Dec 2025 16:32:55 -0800
Subject: [PATCH] feat: old chat to new-chat with persistance
---
.../app/routes/new_chat_routes.py | 76 +++++-
.../dashboard/[search_space_id]/layout.tsx | 2 +-
.../new-chat/[[...chat_id]]/page.tsx | 45 ++--
.../assistant-ui/inline-citation.tsx | 1 -
.../components/assistant-ui/markdown-text.tsx | 20 +-
.../components/assistant-ui/thread-list.tsx | 23 +-
surfsense_web/components/markdown-viewer.tsx | 22 +-
.../new-chat/source-detail-panel.tsx | 216 +++++++++---------
.../components/sidebar/AppSidebarProvider.tsx | 131 +++++------
.../components/sidebar/all-chats-sidebar.tsx | 212 ++++++++++++-----
surfsense_web/components/tool-ui/audio.tsx | 47 +---
.../components/tool-ui/generate-podcast.tsx | 19 +-
surfsense_web/components/tool-ui/index.ts | 1 -
surfsense_web/lib/chat/podcast-state.ts | 1 -
surfsense_web/lib/chat/thread-persistence.ts | 50 ++--
surfsense_web/messages/en.json | 8 +-
surfsense_web/tsconfig.json | 1 -
17 files changed, 490 insertions(+), 385 deletions(-)
diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py
index c3102db67..209c25a15 100644
--- a/surfsense_backend/app/routes/new_chat_routes.py
+++ b/surfsense_backend/app/routes/new_chat_routes.py
@@ -51,6 +51,7 @@ router = APIRouter()
@router.get("/threads", response_model=ThreadListResponse)
async def list_threads(
search_space_id: int,
+ limit: int | None = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
@@ -58,6 +59,10 @@ async def list_threads(
List all threads for the current user in a search space.
Returns threads and archived_threads for ThreadListPrimitive.
+ Args:
+ search_space_id: The search space to list threads for
+ limit: Optional limit on number of threads to return (applies to active threads only)
+
Requires CHATS_READ permission.
"""
try:
@@ -91,14 +96,18 @@ async def list_threads(
id=thread.id,
title=thread.title,
archived=thread.archived,
- createdAt=thread.created_at,
- updatedAt=thread.updated_at,
+ created_at=thread.created_at,
+ updated_at=thread.updated_at,
)
if thread.archived:
archived_threads.append(item)
else:
threads.append(item)
+ # Apply limit to active threads if specified
+ if limit is not None and limit > 0:
+ threads = threads[:limit]
+
return ThreadListResponse(threads=threads, archived_threads=archived_threads)
except HTTPException:
@@ -114,6 +123,69 @@ async def list_threads(
) from None
+@router.get("/threads/search", response_model=list[ThreadListItem])
+async def search_threads(
+ search_space_id: int,
+ title: str,
+ session: AsyncSession = Depends(get_async_session),
+ user: User = Depends(current_active_user),
+):
+ """
+ Search threads by title in a search space.
+
+ Args:
+ search_space_id: The search space to search in
+ title: The search query (case-insensitive partial match)
+
+ Requires CHATS_READ permission.
+ """
+ try:
+ await check_permission(
+ session,
+ user,
+ search_space_id,
+ Permission.CHATS_READ.value,
+ "You don't have permission to read chats in this search space",
+ )
+
+ # Search threads by title (case-insensitive)
+ query = (
+ select(NewChatThread)
+ .filter(
+ NewChatThread.search_space_id == search_space_id,
+ NewChatThread.user_id == user.id,
+ NewChatThread.title.ilike(f"%{title}%"),
+ )
+ .order_by(NewChatThread.updated_at.desc())
+ )
+
+ result = await session.execute(query)
+ threads = result.scalars().all()
+
+ return [
+ ThreadListItem(
+ id=thread.id,
+ title=thread.title,
+ archived=thread.archived,
+ created_at=thread.created_at,
+ updated_at=thread.updated_at,
+ )
+ for thread in threads
+ ]
+
+ except HTTPException:
+ raise
+ except OperationalError:
+ raise HTTPException(
+ status_code=503, detail="Database operation failed. Please try again later."
+ ) from None
+ except Exception as e:
+ raise HTTPException(
+ status_code=500,
+ detail=f"An unexpected error occurred while searching threads: {e!s}",
+ ) from None
+
+
@router.post("/threads", response_model=NewChatThreadRead)
async def create_thread(
thread: NewChatThreadCreate,
diff --git a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx
index 814cf49f4..ce2c778c5 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx
@@ -29,7 +29,7 @@ export default function DashboardLayout({
const customNavMain = [
{
title: "Chat",
- url: `/dashboard/${search_space_id}/researcher`,
+ url: `/dashboard/${search_space_id}/new-chat`,
icon: "SquareTerminal",
items: [],
},
diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
index ab880813e..317209429 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
@@ -2,26 +2,26 @@
import {
AssistantRuntimeProvider,
- useExternalStoreRuntime,
type ThreadMessageLike,
+ useExternalStoreRuntime,
} from "@assistant-ui/react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
import { Thread } from "@/components/assistant-ui/thread";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
-import {
- createThread,
- getThreadMessages,
- appendMessage,
- type MessageRecord,
-} from "@/lib/chat/thread-persistence";
import { getBearerToken } from "@/lib/auth-utils";
-import { toast } from "sonner";
import {
isPodcastGenerating,
looksLikePodcastRequest,
setActivePodcastTaskId,
} from "@/lib/chat/podcast-state";
+import {
+ appendMessage,
+ createThread,
+ getThreadMessages,
+ type MessageRecord,
+} from "@/lib/chat/thread-persistence";
/**
* Convert backend message to assistant-ui ThreadMessageLike format
@@ -223,8 +223,7 @@ export default function NewChatPage() {
]);
try {
- const backendUrl =
- process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
+ const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
// Build message history for context
const messageHistory = messages
@@ -232,11 +231,7 @@ export default function NewChatPage() {
.map((m) => {
let text = "";
for (const part of m.content) {
- if (
- typeof part === "object" &&
- part.type === "text" &&
- "text" in part
- ) {
+ if (typeof part === "object" && part.type === "text" && "text" in part) {
text += part.text;
}
}
@@ -296,9 +291,7 @@ export default function NewChatPage() {
accumulatedText += parsed.delta;
setMessages((prev) =>
prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContent() }
- : m
+ m.id === assistantMsgId ? { ...m, content: buildContent() } : m
)
);
break;
@@ -311,9 +304,7 @@ export default function NewChatPage() {
});
setMessages((prev) =>
prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContent() }
- : m
+ m.id === assistantMsgId ? { ...m, content: buildContent() } : m
)
);
break;
@@ -329,9 +320,7 @@ export default function NewChatPage() {
});
setMessages((prev) =>
prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContent() }
- : m
+ m.id === assistantMsgId ? { ...m, content: buildContent() } : m
)
);
break;
@@ -351,9 +340,7 @@ export default function NewChatPage() {
}
setMessages((prev) =>
prev.map((m) =>
- m.id === assistantMsgId
- ? { ...m, content: buildContent() }
- : m
+ m.id === assistantMsgId ? { ...m, content: buildContent() } : m
)
);
break;
@@ -379,9 +366,7 @@ export default function NewChatPage() {
appendMessage(threadId, {
role: "assistant",
content: finalContent,
- }).catch((err) =>
- console.error("Failed to persist assistant message:", err)
- );
+ }).catch((err) => console.error("Failed to persist assistant message:", err));
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
diff --git a/surfsense_web/components/assistant-ui/inline-citation.tsx b/surfsense_web/components/assistant-ui/inline-citation.tsx
index cdecb13ff..065f37e8e 100644
--- a/surfsense_web/components/assistant-ui/inline-citation.tsx
+++ b/surfsense_web/components/assistant-ui/inline-citation.tsx
@@ -39,4 +39,3 @@ export const InlineCitation: FC = ({ chunkId, citationNumbe
);
};
-
diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx
index ff4bcbef5..41d6143b9 100644
--- a/surfsense_web/components/assistant-ui/markdown-text.tsx
+++ b/surfsense_web/components/assistant-ui/markdown-text.tsx
@@ -9,11 +9,10 @@ import {
useIsMarkdownCodeBlock,
} from "@assistant-ui/react-markdown";
import { CheckIcon, CopyIcon } from "lucide-react";
-import { type FC, type ReactNode, memo, useState } from "react";
+import { type FC, memo, type ReactNode, useState } from "react";
import remarkGfm from "remark-gfm";
-
-import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { InlineCitation } from "@/components/assistant-ui/inline-citation";
+import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { cn } from "@/lib/utils";
// Citation pattern: [citation:CHUNK_ID]
@@ -212,18 +211,12 @@ const defaultComponents = memoizeMarkdownComponents({
),
h6: ({ className, children, ...props }) => (
-
+
{processChildrenWithCitations(children)}
),
p: ({ className, children, ...props }) => (
-
+
{processChildrenWithCitations(children)}
),
@@ -236,10 +229,7 @@ const defaultComponents = memoizeMarkdownComponents({
),
blockquote: ({ className, children, ...props }) => (
-
+
{processChildrenWithCitations(children)}
),
diff --git a/surfsense_web/components/assistant-ui/thread-list.tsx b/surfsense_web/components/assistant-ui/thread-list.tsx
index de479e6b8..f65acd5c6 100644
--- a/surfsense_web/components/assistant-ui/thread-list.tsx
+++ b/surfsense_web/components/assistant-ui/thread-list.tsx
@@ -1,9 +1,15 @@
"use client";
-import { useCallback, useEffect, useState } from "react";
+import {
+ ArchiveIcon,
+ MessageSquareIcon,
+ MoreVerticalIcon,
+ PlusIcon,
+ RotateCcwIcon,
+ TrashIcon,
+} from "lucide-react";
import { useRouter } from "next/navigation";
-import { ArchiveIcon, MessageSquareIcon, PlusIcon, TrashIcon, MoreVerticalIcon, RotateCcwIcon } from "lucide-react";
-import { cn } from "@/lib/utils";
+import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -13,10 +19,11 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
- type ThreadListItem,
createThreadListManager,
+ type ThreadListItem,
type ThreadListState,
} from "@/lib/chat/thread-persistence";
+import { cn } from "@/lib/utils";
interface ThreadListProps {
searchSpaceId: number;
@@ -123,7 +130,13 @@ export function ThreadList({ searchSpaceId, currentThreadId, className }: Thread
{/* Header with New Chat button */}
diff --git a/surfsense_web/components/markdown-viewer.tsx b/surfsense_web/components/markdown-viewer.tsx
index a0686e1ad..5318ba5d1 100644
--- a/surfsense_web/components/markdown-viewer.tsx
+++ b/surfsense_web/components/markdown-viewer.tsx
@@ -1,6 +1,6 @@
import Image from "next/image";
-import { Streamdown } from "streamdown";
import type { Components } from "react-markdown";
+import { Streamdown } from "streamdown";
import { cn } from "@/lib/utils";
interface MarkdownViewerProps {
@@ -68,12 +68,8 @@ export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
),
- th: ({ ...props }) => (
- |
- ),
- td: ({ ...props }) => (
- |
- ),
+ th: ({ ...props }) => | ,
+ td: ({ ...props }) => | ,
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
@@ -96,11 +92,13 @@ export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
};
return (
-
-
+
+
{content}
diff --git a/surfsense_web/components/new-chat/source-detail-panel.tsx b/surfsense_web/components/new-chat/source-detail-panel.tsx
index a076586a7..6e3e7cce0 100644
--- a/surfsense_web/components/new-chat/source-detail-panel.tsx
+++ b/surfsense_web/components/new-chat/source-detail-panel.tsx
@@ -1,26 +1,26 @@
"use client";
import { useQuery } from "@tanstack/react-query";
-import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
+ BookOpen,
ChevronDown,
ChevronUp,
ExternalLink,
- Loader2,
- X,
FileText,
Hash,
- BookOpen,
+ Loader2,
Sparkles,
+ X,
} from "lucide-react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type React from "react";
-import { type ReactNode, forwardRef, useCallback, useEffect, useRef, useState } from "react";
+import { forwardRef, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { MarkdownViewer } from "@/components/markdown-viewer";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
-import { Badge } from "@/components/ui/badge";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
@@ -70,9 +70,7 @@ const ChunkCard = forwardRef(
)}
>
{/* Cited indicator glow effect */}
- {isCited && (
-
- )}
+ {isCited && }
{/* Header */}
@@ -87,9 +85,7 @@ const ChunkCard = forwardRef(
>
{index + 1}
-
- of {totalChunks} chunks
-
+ of {totalChunks} chunks
{isCited && (
@@ -152,86 +148,97 @@ export function SourceDetailPanel({
const citedChunkIndex = documentData?.chunks?.findIndex((chunk) => chunk.id === chunkId) ?? -1;
// Simple scroll function that scrolls to a chunk by index
- const scrollToChunkByIndex = useCallback((chunkIndex: number, smooth = true) => {
- const scrollContainer = scrollAreaRef.current;
- if (!scrollContainer) return;
+ const scrollToChunkByIndex = useCallback(
+ (chunkIndex: number, smooth = true) => {
+ const scrollContainer = scrollAreaRef.current;
+ if (!scrollContainer) return;
- const viewport = scrollContainer.querySelector(
- '[data-radix-scroll-area-viewport]'
- ) as HTMLElement | null;
- if (!viewport) return;
+ const viewport = scrollContainer.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement | null;
+ if (!viewport) return;
- const chunkElement = scrollContainer.querySelector(
- `[data-chunk-index="${chunkIndex}"]`
- ) as HTMLElement | null;
- if (!chunkElement) return;
+ const chunkElement = scrollContainer.querySelector(
+ `[data-chunk-index="${chunkIndex}"]`
+ ) as HTMLElement | null;
+ if (!chunkElement) return;
- // Get positions using getBoundingClientRect for accuracy
- const viewportRect = viewport.getBoundingClientRect();
- const chunkRect = chunkElement.getBoundingClientRect();
+ // Get positions using getBoundingClientRect for accuracy
+ const viewportRect = viewport.getBoundingClientRect();
+ const chunkRect = chunkElement.getBoundingClientRect();
- // Calculate where to scroll to center the chunk
- const currentScrollTop = viewport.scrollTop;
- const chunkTopRelativeToViewport = chunkRect.top - viewportRect.top + currentScrollTop;
- const scrollTarget = chunkTopRelativeToViewport - (viewportRect.height / 2) + (chunkRect.height / 2);
+ // Calculate where to scroll to center the chunk
+ const currentScrollTop = viewport.scrollTop;
+ const chunkTopRelativeToViewport = chunkRect.top - viewportRect.top + currentScrollTop;
+ const scrollTarget =
+ chunkTopRelativeToViewport - viewportRect.height / 2 + chunkRect.height / 2;
- viewport.scrollTo({
- top: Math.max(0, scrollTarget),
- behavior: smooth && !shouldReduceMotion ? "smooth" : "auto",
- });
-
- setActiveChunkIndex(chunkIndex);
- }, [shouldReduceMotion]);
-
- // Callback ref for the cited chunk - scrolls when the element mounts
- const citedChunkRefCallback = useCallback((node: HTMLDivElement | null) => {
- if (node && !hasScrolledRef.current && open) {
- hasScrolledRef.current = true; // Mark immediately to prevent duplicate scrolls
-
- // Store the node reference for the delayed scroll
- const scrollToCitedChunk = () => {
- const scrollContainer = scrollAreaRef.current;
- if (!scrollContainer || !node.isConnected) return false;
-
- const viewport = scrollContainer.querySelector(
- '[data-radix-scroll-area-viewport]'
- ) as HTMLElement | null;
- if (!viewport) return false;
-
- // Get positions
- const viewportRect = viewport.getBoundingClientRect();
- const chunkRect = node.getBoundingClientRect();
-
- // Calculate scroll position to center the chunk
- const currentScrollTop = viewport.scrollTop;
- const chunkTopRelativeToViewport = chunkRect.top - viewportRect.top + currentScrollTop;
- const scrollTarget = chunkTopRelativeToViewport - (viewportRect.height / 2) + (chunkRect.height / 2);
-
- viewport.scrollTo({
- top: Math.max(0, scrollTarget),
- behavior: "auto", // Instant scroll for initial positioning
- });
-
- return true;
- };
-
- // Scroll multiple times with delays to handle progressive content rendering
- // Each subsequent scroll will correct for any layout shifts
- const scrollAttempts = [50, 150, 300, 600, 1000];
-
- scrollAttempts.forEach((delay) => {
- setTimeout(() => {
- scrollToCitedChunk();
- }, delay);
+ viewport.scrollTo({
+ top: Math.max(0, scrollTarget),
+ behavior: smooth && !shouldReduceMotion ? "smooth" : "auto",
});
- // After final attempt, mark state as scrolled
- setTimeout(() => {
- setHasScrolledToCited(true);
- setActiveChunkIndex(citedChunkIndex);
- }, scrollAttempts[scrollAttempts.length - 1] + 50);
- }
- }, [open, citedChunkIndex]);
+ setActiveChunkIndex(chunkIndex);
+ },
+ [shouldReduceMotion]
+ );
+
+ // Callback ref for the cited chunk - scrolls when the element mounts
+ const citedChunkRefCallback = useCallback(
+ (node: HTMLDivElement | null) => {
+ if (node && !hasScrolledRef.current && open) {
+ hasScrolledRef.current = true; // Mark immediately to prevent duplicate scrolls
+
+ // Store the node reference for the delayed scroll
+ const scrollToCitedChunk = () => {
+ const scrollContainer = scrollAreaRef.current;
+ if (!scrollContainer || !node.isConnected) return false;
+
+ const viewport = scrollContainer.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement | null;
+ if (!viewport) return false;
+
+ // Get positions
+ const viewportRect = viewport.getBoundingClientRect();
+ const chunkRect = node.getBoundingClientRect();
+
+ // Calculate scroll position to center the chunk
+ const currentScrollTop = viewport.scrollTop;
+ const chunkTopRelativeToViewport = chunkRect.top - viewportRect.top + currentScrollTop;
+ const scrollTarget =
+ chunkTopRelativeToViewport - viewportRect.height / 2 + chunkRect.height / 2;
+
+ viewport.scrollTo({
+ top: Math.max(0, scrollTarget),
+ behavior: "auto", // Instant scroll for initial positioning
+ });
+
+ return true;
+ };
+
+ // Scroll multiple times with delays to handle progressive content rendering
+ // Each subsequent scroll will correct for any layout shifts
+ const scrollAttempts = [50, 150, 300, 600, 1000];
+
+ scrollAttempts.forEach((delay) => {
+ setTimeout(() => {
+ scrollToCitedChunk();
+ }, delay);
+ });
+
+ // After final attempt, mark state as scrolled
+ setTimeout(
+ () => {
+ setHasScrolledToCited(true);
+ setActiveChunkIndex(citedChunkIndex);
+ },
+ scrollAttempts[scrollAttempts.length - 1] + 50
+ );
+ }
+ },
+ [open, citedChunkIndex]
+ );
// Reset scroll state when panel closes
useEffect(() => {
@@ -271,9 +278,12 @@ export function SourceDetailPanel({
window.open(clickUrl, "_blank", "noopener,noreferrer");
};
- const scrollToChunk = useCallback((index: number) => {
- scrollToChunkByIndex(index, true);
- }, [scrollToChunkByIndex]);
+ const scrollToChunk = useCallback(
+ (index: number) => {
+ scrollToChunkByIndex(index, true);
+ },
+ [scrollToChunkByIndex]
+ );
const panelContent = (
@@ -320,7 +330,8 @@ export function SourceDetailPanel({
: sourceType && formatDocumentType(sourceType)}
{documentData?.chunks && (
- • {documentData.chunks.length} chunk{documentData.chunks.length !== 1 ? "s" : ""}
+ • {documentData.chunks.length} chunk
+ {documentData.chunks.length !== 1 ? "s" : ""}
)}
@@ -378,9 +389,12 @@ export function SourceDetailPanel({
-
Failed to load document
+
+ Failed to load document
+
- {documentByChunkFetchingError.message || "An unexpected error occurred. Please try again."}
+ {documentByChunkFetchingError.message ||
+ "An unexpected error occurred. Please try again."}