From 3740171a5ce7625545cb596e38cb70ce81c976d3 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:12:30 +0530 Subject: [PATCH 01/34] feat(layout): add cookie persistence for playground sidebar state management --- .../components/layout/ui/shell/LayoutShell.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 7a42d5a2e..8578b7dec 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -44,6 +44,20 @@ const DocumentTabContent = dynamic( const PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE = "surfsense_playground_sidebar_collapsed"; const PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; +function persistPlaygroundSidebarCollapsedCookie(isCollapsed: boolean) { + void window.cookieStore + ?.set({ + name: PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE, + value: String(isCollapsed), + path: "/", + expires: Date.now() + PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE * 1000, + sameSite: "lax", + }) + .catch(() => { + // Ignore preference persistence failures. + }); +} + function MacDesktopTitleBar({ isSidebarCollapsed, onToggleSidebar, @@ -242,8 +256,7 @@ export function LayoutShell({ const handlePlaygroundSidebarToggle = () => { setIsPlaygroundSidebarCollapsed((collapsed) => { const nextCollapsed = !collapsed; - const secureAttribute = window.location.protocol === "https:" ? "; Secure" : ""; - document.cookie = `${PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE}=${nextCollapsed}; Path=/; Max-Age=${PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE}; SameSite=Lax${secureAttribute}`; + persistPlaygroundSidebarCollapsedCookie(nextCollapsed); return nextCollapsed; }); }; From 7889babb8e8743b7938f8eb60cef1ee9a944c345 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:11:41 +0530 Subject: [PATCH 02/34] feat(sidebar): add loading skeleton for threads in AllChatsSidebar component --- .../layout/ui/sidebar/AllChatsSidebar.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx index 3063a9c50..04780bf3e 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx @@ -94,7 +94,9 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) { const { data: threadsData, error: threadsError, + isFetching: isFetchingThreads, isLoading: isLoadingThreads, + isPlaceholderData, } = useQuery({ queryKey: ["all-threads", workspaceId], queryFn: () => fetchThreads(Number(workspaceId)), @@ -228,6 +230,7 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) { }, []); const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads; + const isLoadingMoreThreads = !isSearchMode && isFetchingThreads && isPlaceholderData; const error = isSearchMode ? searchError : threadsError; const selectedFilterLabel = showArchived ? "Archived" : "Active"; @@ -307,10 +310,12 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) { {[75, 90, 55, 80, 65, 85].map((titleWidth) => (
- - +
))} @@ -485,6 +490,21 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) { ); })} + {isLoadingMoreThreads ? ( +
+ {[62, 48, 56].map((titleWidth) => ( +
+ +
+ ))} +
+ ) : null} ) : isSearchMode ? (
From a466fdcf5d5dd54393a1f65cf85bd25210ee3c94 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:12:51 +0530 Subject: [PATCH 03/34] refactor(AllChatsSidebar): replace timestamp formatting with relative date display and adjust button styles --- .../layout/ui/sidebar/AllChatsSidebar.tsx | 102 ++++++++---------- surfsense_web/lib/format-date.ts | 22 ++-- 2 files changed, 56 insertions(+), 68 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx index 04780bf3e..e7cdb7d5e 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx @@ -37,14 +37,13 @@ import { import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useActivateChatThread } from "@/hooks/use-activate-chat-thread"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useLongPress } from "@/hooks/use-long-press"; import { useIsMobile } from "@/hooks/use-mobile"; import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations"; import { fetchThreads, searchThreads, type ThreadListItem } from "@/lib/chat/thread-persistence"; -import { formatThreadTimestamp } from "@/lib/format-date"; +import { formatRelativeDate } from "@/lib/format-date"; import { cn } from "@/lib/utils"; interface AllChatsContentProps { @@ -288,7 +287,7 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) { placeholder={t("search_chats") || "Search chats..."} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - className="h-12 border-0 bg-muted pl-10 pr-9 text-base shadow-none" + className="h-10 border-0 bg-muted pl-10 pr-9 text-base shadow-none" /> {searchQuery && ( ) : ( - - - - - -

- {t("updated") || "Updated"}: {formatThreadTimestamp(thread.updatedAt)} -

-
-
+ )}
-
- {thread.visibility === "SEARCH_SPACE" ? ( - - Shared - - ) : null} +
+
+ {thread.visibility === "SEARCH_SPACE" ? ( + + Shared + + ) : null} + + {formatRelativeDate(thread.updatedAt)} + +
setOpenDropdownId(isOpen ? thread.id : null)} @@ -436,11 +426,9 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) { variant="ghost" size="icon" className={cn( - "pointer-events-auto h-7 w-7 hover:bg-transparent", + "pointer-events-auto absolute right-0 h-7 w-7 hover:bg-transparent", openDropdownId === thread.id && "bg-accent hover:bg-accent", - !isMobile && - openDropdownId !== thread.id && - "opacity-0 group-hover/item:opacity-100" + openDropdownId !== thread.id && "opacity-0 group-hover/item:opacity-100" )} disabled={isBusy} > diff --git a/surfsense_web/lib/format-date.ts b/surfsense_web/lib/format-date.ts index a062b08fa..06ae77c64 100644 --- a/surfsense_web/lib/format-date.ts +++ b/surfsense_web/lib/format-date.ts @@ -5,29 +5,29 @@ import { isThisYear, isToday, isTomorrow, - isYesterday, } from "date-fns"; /** * Format a date string as a human-readable relative time * - < 1 min: "Just now" - * - < 60 min: "15m ago" - * - Today: "Today, 2:30 PM" - * - Yesterday: "Yesterday, 2:30 PM" - * - < 7 days: "3d ago" + * - < 60 min: "15 minutes ago" + * - < 24 hours: "21 hours ago" + * - < 7 days: "2 days ago" + * - Older this year: "Jan 15" * - Older: "Jan 15, 2026" */ export function formatRelativeDate(dateString: string): string { const date = new Date(dateString); const now = new Date(); - const minutesAgo = differenceInMinutes(now, date); - const daysAgo = differenceInDays(now, date); + const minutesAgo = Math.max(0, differenceInMinutes(now, date)); + const hoursAgo = Math.floor(minutesAgo / 60); + const daysAgo = Math.floor(hoursAgo / 24); if (minutesAgo < 1) return "Just now"; - if (minutesAgo < 60) return `${minutesAgo}m ago`; - if (isToday(date)) return `Today, ${format(date, "h:mm a")}`; - if (isYesterday(date)) return `Yesterday, ${format(date, "h:mm a")}`; - if (daysAgo < 7) return `${daysAgo}d ago`; + if (minutesAgo < 60) return `${minutesAgo} minute${minutesAgo === 1 ? "" : "s"} ago`; + if (hoursAgo < 24) return `${hoursAgo} hour${hoursAgo === 1 ? "" : "s"} ago`; + if (daysAgo < 7) return `${daysAgo} day${daysAgo === 1 ? "" : "s"} ago`; + if (isThisYear(date)) return format(date, "MMM d"); return format(date, "MMM d, yyyy"); } From 8aaf9cadbe948c4771ba918f20256fd803e4b616 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:16:38 +0530 Subject: [PATCH 04/34] feat(sidebar): add onChatsClick handler to sidebar components for improved navigation --- .../layout/providers/LayoutDataProvider.tsx | 4 ++++ .../components/layout/ui/shell/LayoutShell.tsx | 4 ++++ .../components/layout/ui/sidebar/MobileSidebar.tsx | 10 ++++++++++ .../components/layout/ui/sidebar/Sidebar.tsx | 14 +++++++++++++- 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 7fc862e49..682d2923e 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -631,6 +631,9 @@ export function LayoutDataProvider({ const isArtifactsPage = pathname?.endsWith("/artifacts") === true; const isPlaygroundPage = pathname?.includes("/playground") === true; const isAllChatsPage = pathname?.endsWith("/chats") === true; + const handleChatsClick = useCallback(() => { + router.push(`/dashboard/${workspaceId}/chats`); + }, [router, workspaceId]); const handleViewAllChats = useCallback(() => { router.push( isAllChatsPage ? `/dashboard/${workspaceId}/new-chat` : `/dashboard/${workspaceId}/chats` @@ -671,6 +674,7 @@ export function LayoutDataProvider({ onChatRename={handleChatRename} onChatDelete={handleChatDelete} onChatArchive={handleChatArchive} + onChatsClick={handleChatsClick} onViewAllChats={handleViewAllChats} user={{ email: user?.email || "", diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 8578b7dec..44a2259ee 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -106,6 +106,7 @@ interface LayoutShellProps { onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; + onChatsClick?: () => void; onViewAllChats?: () => void; user: User; onSettings?: () => void; @@ -208,6 +209,7 @@ export function LayoutShell({ onChatRename, onChatDelete, onChatArchive, + onChatsClick, onViewAllChats, user, onSettings, @@ -289,6 +291,7 @@ export function LayoutShell({ onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} + onChatsClick={onChatsClick} onViewAllChats={onViewAllChats} isAllChatsActive={isAllChatsPage} user={user} @@ -388,6 +391,7 @@ export function LayoutShell({ onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} + onChatsClick={onChatsClick} onViewAllChats={onViewAllChats} isAllChatsActive={isAllChatsPage} user={user} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 1d02b56f4..de1b08a6a 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -28,6 +28,7 @@ interface MobileSidebarProps { onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; + onChatsClick?: () => void; onViewAllChats?: () => void; isAllChatsActive?: boolean; user: User; @@ -77,6 +78,7 @@ export function MobileSidebar({ onChatRename, onChatDelete, onChatArchive, + onChatsClick, onViewAllChats, isAllChatsActive = false, user, @@ -194,6 +196,14 @@ export function MobileSidebar({ onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} + onChatsClick={ + onChatsClick + ? () => { + onOpenChange(false); + onChatsClick(); + } + : undefined + } onViewAllChats={ onViewAllChats ? () => { diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 7862ae90e..e24523448 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { CreditCard, SquarePen, Zap } from "lucide-react"; +import { CreditCard, MessageCircleMore, SquarePen, Zap } from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -62,6 +62,7 @@ interface SidebarProps { onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; + onChatsClick?: () => void; onViewAllChats?: () => void; isAllChatsActive?: boolean; user: User; @@ -101,6 +102,7 @@ export function Sidebar({ onChatRename, onChatDelete, onChatArchive, + onChatsClick, onViewAllChats, isAllChatsActive = false, user, @@ -222,6 +224,16 @@ export function Sidebar({ onScroll={(event) => setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)} >
+ {onChatsClick && ( + + )} {automationsItem && ( Date: Wed, 15 Jul 2026 21:19:37 +0530 Subject: [PATCH 05/34] refactor(automations-loading): update skeleton loading styles for improved alignment and consistency --- .../components/automations-loading.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx index 1156be3f6..058e61598 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx @@ -13,21 +13,18 @@ export function AutomationsLoadingRows() { return ( <> {ROW_KEYS.map((key) => ( - - -
- - -
+ + + - + - + - - + + ))} From 7246f89c124fe7c515350777ac24c4aad393f7b3 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:29:18 +0530 Subject: [PATCH 06/34] refactor(chat): enhance message loading state management and update skeleton loading components for better user experience --- .../new-chat/[[...chat_id]]/page.tsx | 104 +++++------------- .../components/assistant-ui/thread.tsx | 89 ++++++++++++--- 2 files changed, 98 insertions(+), 95 deletions(-) diff --git a/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx index f5b857ce1..22298e574 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx @@ -39,7 +39,6 @@ import { TokenUsageProvider, } from "@/components/assistant-ui/token-usage-context"; import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; import { useSyncChatArtifacts } from "@/features/chat-artifacts"; import { type HitlDecision, @@ -104,6 +103,7 @@ const MobileArtifactsPanel = dynamic( /** Stable empty reference so idle threads don't re-render the interrupt provider. */ const EMPTY_PENDING_INTERRUPTS: PendingInterruptState[] = []; +const EMPTY_MESSAGES: ThreadMessageLike[] = []; function parseUrlChatId(id: string | string[] | undefined): number { let parsed = 0; @@ -115,61 +115,6 @@ function parseUrlChatId(id: string | string[] | undefined): number { return Number.isNaN(parsed) ? 0 : parsed; } -function ThreadMessagesSkeleton() { - return ( -
-
-
-
-
- -
- -
- - - -
- -
- -
- -
- - - -
- -
- -
-
- -
-
- -
-
-
-
- ); -} - export default function NewChatPage() { const params = useParams(); const queryClient = useQueryClient(); @@ -226,6 +171,16 @@ export default function NewChatPage() { const threadDetailQuery = useThreadDetail(activeThreadId); const threadMessagesQuery = useThreadMessages(activeThreadId); + const hydratedMessagesRef = useRef<{ + threadId: number | null; + data: typeof threadMessagesQuery.data; + }>({ threadId: null, data: undefined }); + const hasLiveStream = !!streamState && streamState.messages.length > 0; + const isActiveThreadHydrated = hydratedMessagesRef.current.threadId === activeThreadId; + const shouldHideStaleMessages = !!activeThreadId && !hasLiveStream && !isActiveThreadHydrated; + const isThreadMessagesLoading = + shouldHideStaleMessages && !threadMessagesQuery.error; + const runtimeMessages = shouldHideStaleMessages ? EMPTY_MESSAGES : displayMessages; // Live collaboration: sync session state and messages via Zero. Kept on the // page because "AI responding" reflects the currently-viewed thread. @@ -296,8 +251,8 @@ export default function NewChatPage() { // Latest displayed messages, read by the engine wrappers at call time so // history/slice seeds stay fresh without re-creating the callbacks. - const messagesRef = useRef(displayMessages); - messagesRef.current = displayMessages; + const messagesRef = useRef(runtimeMessages); + messagesRef.current = runtimeMessages; const buildCtx = useCallback( (): EngineContext => ({ @@ -309,11 +264,6 @@ export default function NewChatPage() { [workspaceId, activeThreadId] ); - const hydratedMessagesRef = useRef<{ - threadId: number | null; - data: typeof threadMessagesQuery.data; - }>({ threadId: null, data: undefined }); - // Reset thread-local runtime state on route/workspace changes. The durable // streaming overlay is preserved for any still-running thread (and the newly // viewed thread) via ``clearInactive`` so an in-flight turn survives nav. @@ -517,8 +467,11 @@ export default function NewChatPage() { // Handle new message from user const onNew = useCallback( - (message: AppendMessage) => startNewChat(buildCtx(), message), - [buildCtx] + (message: AppendMessage) => { + if (isThreadMessagesLoading) return Promise.resolve(); + return startNewChat(buildCtx(), message); + }, + [buildCtx, isThreadMessagesLoading] ); // Cancel the in-flight turn (targets the active stream's owner thread). @@ -747,11 +700,11 @@ export default function NewChatPage() { }, [buildCtx, pendingInterrupts, activeThreadId]); // Surface the thread's deliverables to the layout-level artifacts sidebar. - useSyncChatArtifacts(displayMessages); + useSyncChatArtifacts(runtimeMessages); // Create external store runtime const runtime = useExternalStoreRuntime({ - messages: displayMessages, + messages: runtimeMessages, isRunning, onNew, onEdit, @@ -764,12 +717,7 @@ export default function NewChatPage() { ? (threadDetailQuery.error ?? threadMessagesQuery.error) : null; const shouldShowThreadLoadError = - !!threadLoadError && !!activeThreadId && !currentThread && displayMessages.length === 0; - const isThreadMessagesLoading = - !!activeThreadId && - threadMessagesQuery.isPending && - displayMessages.length === 0 && - !threadMessagesQuery.error; + !!threadLoadError && !!activeThreadId && !currentThread && runtimeMessages.length === 0; if (shouldShowThreadLoadError) { return ( @@ -798,12 +746,10 @@ export default function NewChatPage() { >
- - {isThreadMessagesLoading ? ( -
- -
- ) : null} +
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index c51bea165..634d9bd8b 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -103,6 +103,7 @@ import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI } from "@/hooks/use-platform"; import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities"; import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture"; +import { canSubmitChat } from "@/lib/chat/can-submit-chat"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { slideoutOpenedTickAtom } from "@/lib/layout-events"; import { findPlatform, type PlaygroundPlatform } from "@/lib/playground/catalog"; @@ -147,13 +148,19 @@ function getComposerSuggestionAnchorPoint( interface ThreadProps { hasActiveThread?: boolean; + isLoadingMessages?: boolean; } -export const Thread: FC = ({ hasActiveThread = false }) => { - return ; +export const Thread: FC = ({ hasActiveThread = false, isLoadingMessages = false }) => { + return ( + + ); }; -const ThreadContent: FC = ({ hasActiveThread = false }) => { +const ThreadContent: FC = ({ + hasActiveThread = false, + isLoadingMessages = false, +}) => { return ( = ({ hasActiveThread = false }) => { footer={ hasActiveThread || !thread.isEmpty}> - + } > @@ -173,6 +180,8 @@ const ThreadContent: FC = ({ hasActiveThread = false }) => { + {isLoadingMessages ? : null} + = ({ hasActiveThread = false }) => { ); }; +const ThreadMessagesSkeletonBody: FC = () => { + return ( +
+
+ +
+ +
+ + + +
+ +
+ +
+ +
+ + + +
+ +
+ +
+
+ ); +}; + const PremiumQuotaPinnedAlert: FC = () => { const currentThreadState = useAtomValue(currentThreadAtom); const alertsByThread = useAtomValue(premiumAlertByThreadAtom); @@ -490,7 +529,11 @@ const ChatUnavailableNotice: FC<{ workspaceId: number; canConfigure: boolean }> ); }; -const Composer: FC = () => { +interface ComposerProps { + isLoadingMessages?: boolean; +} + +const Composer: FC = ({ isLoadingMessages = false }) => { const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const setSubmittedMentions = useSetAtom(submittedMentionsAtom); const [showDocumentPopover, setShowDocumentPopover] = useState(false); @@ -823,7 +866,7 @@ const Composer: FC = () => { ); const handleSubmit = useCallback(() => { - if (isThreadRunning || isBlockedByOtherUser) return; + if (isLoadingMessages || isThreadRunning || isBlockedByOtherUser) return; if (showDocumentPopover || showPromptPicker) return; if (clipboardInitialText) { @@ -844,6 +887,7 @@ const Composer: FC = () => { }, [ showDocumentPopover, showPromptPicker, + isLoadingMessages, isThreadRunning, isBlockedByOtherUser, clipboardInitialText, @@ -1016,15 +1060,17 @@ const Composer: FC = () => {
- {isThreadEmpty && isComposerInputEmpty ? ( + {!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? (
@@ -1087,12 +1133,16 @@ const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) => interface ComposerActionProps { isBlockedByOtherUser?: boolean; + isLoadingMessages?: boolean; + isThreadRunning?: boolean; workspaceId: number; onChatModelSelected?: () => void; } const ComposerAction: FC = ({ isBlockedByOtherUser = false, + isLoadingMessages = false, + isThreadRunning = false, workspaceId, onChatModelSelected, }) => { @@ -1210,7 +1260,20 @@ const ComposerAction: FC = ({ // send that lacks a resolvable model, making this defense-in-depth. const isWorkspaceChatReady = setupStatus?.status === "ready"; - const isSendDisabled = isComposerEmpty || !isWorkspaceChatReady || isBlockedByOtherUser; + const isSendDisabled = !canSubmitChat({ + isLoadingMessages, + isThreadRunning, + isBlockedByOtherUser, + isComposerEmpty, + isWorkspaceChatReady, + }); + const sendTooltip = isLoadingMessages + ? "Loading conversation..." + : isBlockedByOtherUser + ? "Wait for AI to finish responding" + : isComposerEmpty + ? "Enter a message or add a screenshot to send" + : "Send message"; return (
@@ -1661,13 +1724,7 @@ const ComposerAction: FC = ({ !thread.isRunning}> Date: Thu, 16 Jul 2026 01:29:32 +0530 Subject: [PATCH 07/34] feat(chat): add CanSubmitChat interface and logic to determine chat submission eligibility --- surfsense_web/lib/chat/can-submit-chat.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 surfsense_web/lib/chat/can-submit-chat.ts diff --git a/surfsense_web/lib/chat/can-submit-chat.ts b/surfsense_web/lib/chat/can-submit-chat.ts new file mode 100644 index 000000000..106b298af --- /dev/null +++ b/surfsense_web/lib/chat/can-submit-chat.ts @@ -0,0 +1,23 @@ +export interface CanSubmitChatInput { + isLoadingMessages: boolean; + isThreadRunning: boolean; + isBlockedByOtherUser: boolean; + isComposerEmpty: boolean; + isWorkspaceChatReady: boolean; +} + +export function canSubmitChat({ + isLoadingMessages, + isThreadRunning, + isBlockedByOtherUser, + isComposerEmpty, + isWorkspaceChatReady, +}: CanSubmitChatInput): boolean { + return ( + !isLoadingMessages && + !isThreadRunning && + !isBlockedByOtherUser && + !isComposerEmpty && + isWorkspaceChatReady + ); +} From b100eda8c63f30178938ced147fb9b6ecaf66df6 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:11:54 +0530 Subject: [PATCH 08/34] refactor(chat-header): update className handling for responsive design and improve layout consistency --- surfsense_web/components/assistant-ui/thread.tsx | 6 +++--- surfsense_web/components/new-chat/chat-header.tsx | 9 ++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 634d9bd8b..f11e82952 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -102,8 +102,8 @@ import { useCommentsSync } from "@/hooks/use-comments-sync"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI } from "@/hooks/use-platform"; import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities"; -import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture"; import { canSubmitChat } from "@/lib/chat/can-submit-chat"; +import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { slideoutOpenedTickAtom } from "@/lib/layout-events"; import { findPlatform, type PlaygroundPlatform } from "@/lib/playground/catalog"; @@ -1715,10 +1715,10 @@ const ComposerAction: FC = ({ )}
-
+
!thread.isRunning}> diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx index e83a95d6c..f315f7645 100644 --- a/surfsense_web/components/new-chat/chat-header.tsx +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -1,5 +1,6 @@ "use client"; +import { cn } from "@/lib/utils"; import { ImageModelSelector } from "./image-model-selector"; import { ModelSelector } from "./model-selector"; @@ -10,14 +11,16 @@ interface ChatHeaderProps { } export function ChatHeader({ workspaceId, className, onChatModelSelected }: ChatHeaderProps) { + const selectorClassName = cn(className, "sm:max-w-[180px] sm:min-w-0"); + return ( -
+
- +
); } From 804e5e42f3da0ed4d05b15ee54b98dfd6fa630d6 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:34:27 +0530 Subject: [PATCH 09/34] refactor(citation-panel): remove showHeader prop and enhance close button functionality in CitationPanelContent --- .../assistant-ui/inline-citation.tsx | 2 +- .../citation-panel/citation-panel.tsx | 48 ++++++++----------- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/surfsense_web/components/assistant-ui/inline-citation.tsx b/surfsense_web/components/assistant-ui/inline-citation.tsx index a66c22fa3..a846e8311 100644 --- a/surfsense_web/components/assistant-ui/inline-citation.tsx +++ b/surfsense_web/components/assistant-ui/inline-citation.tsx @@ -100,7 +100,7 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => { Citation
- +
diff --git a/surfsense_web/components/citation-panel/citation-panel.tsx b/surfsense_web/components/citation-panel/citation-panel.tsx index 34cc89c8b..f501281b9 100644 --- a/surfsense_web/components/citation-panel/citation-panel.tsx +++ b/surfsense_web/components/citation-panel/citation-panel.tsx @@ -8,6 +8,7 @@ import { useEffect, useMemo, useRef } from "react"; import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { MarkdownViewer } from "@/components/markdown-viewer"; import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; import { Spinner } from "@/components/ui/spinner"; import { documentsApiService } from "@/lib/apis/documents-api.service"; @@ -16,7 +17,6 @@ const DEFAULT_CHUNK_WINDOW = 5; interface CitationPanelContentProps { chunkId: number; onClose?: () => void; - showHeader?: boolean; } /** @@ -25,11 +25,7 @@ interface CitationPanelContentProps { * with the cited one visually highlighted and auto-scrolled into view. * The user can jump to the full document via the editor panel. */ -export const CitationPanelContent: FC = ({ - chunkId, - onClose, - showHeader = true, -}) => { +export const CitationPanelContent: FC = ({ chunkId, onClose }) => { const openEditorPanel = useSetAtom(openEditorPanelAtom); const chunkWindow = DEFAULT_CHUNK_WINDOW; @@ -85,32 +81,13 @@ export const CitationPanelContent: FC = ({ return ( <>
- {showHeader && ( -
-

Citation

-
- {onClose && ( - - )} -
-
- )} -
+

{data?.title ?? (isLoading ? "Loading…" : `Chunk #${chunkId}`)}

-
- {totalChunks > 0 && {totalChunks} chunks} +
{!isLoading && !error && data && ( )} + {onClose && ( + <> + + + + )}
From fc2366c81e87d32aeb12f0a5b2e0ff26f9eb8712 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:40:44 +0530 Subject: [PATCH 10/34] refactor(report-panel): enhance header layout and integrate Separator component for improved UI consistency --- .../components/report-panel/report-panel.tsx | 69 ++++++++++--------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 1fce9848c..076d10255 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -18,6 +18,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Separator } from "@/components/ui/separator"; import { Spinner } from "@/components/ui/spinner"; import { useMediaQuery } from "@/hooks/use-media-query"; import { baseApiService } from "@/lib/apis/base-api.service"; @@ -444,39 +445,45 @@ export function ReportPanelContent({ return ( <> {showDesktopHeader ? ( - <> +
{/* Header — matches the Documents panel header pattern */} -
-

{isResume ? "Resume" : "Report"}

- {onClose && ( - - )} -
- - {!isResume && ( -
-
-

- {reportContent?.title || title} -

-
-
- {versionSwitcher} - {exportButton} - {copyButton} - {editingActions} -
+
+
+

+ {isResume ? "Resume" : reportContent?.title || title} +

- )} - +
+ {!isResume && ( + <> + {versionSwitcher} + {exportButton} + {copyButton} + {editingActions} + + )} + {onClose && ( + <> + {!isResume && ( + + )} + + + )} +
+
+
) : ( !isResume && (
From bf9d536757586a28a17a4102b7d43d3c6f3ebbf9 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:58:45 +0530 Subject: [PATCH 11/34] refactor(artifacts-panel): improve header layout and button styles for better UI consistency --- .../chat-artifacts/ui/artifacts-panel.tsx | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx b/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx index 7b3567d73..8c5b575ad 100644 --- a/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx +++ b/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx @@ -70,19 +70,25 @@ export function ArtifactsPanelContent({ onClose }: { onClose?: () => void }) { return ( <> -
-

Artifacts

- {onClose && ( - - )} +
+
+
+

Artifacts

+
+
+ {onClose && ( + + )} +
+
From 1385ff4789e06e7f3a51b1004df2826f7bab6087 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:14:10 +0530 Subject: [PATCH 12/34] refactor(connect-agent-dialog): update dialog title for clarity and improved user understanding --- surfsense_web/components/mcp/connect-agent-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_web/components/mcp/connect-agent-dialog.tsx b/surfsense_web/components/mcp/connect-agent-dialog.tsx index e46f602ec..417877b2d 100644 --- a/surfsense_web/components/mcp/connect-agent-dialog.tsx +++ b/surfsense_web/components/mcp/connect-agent-dialog.tsx @@ -43,7 +43,7 @@ export function ConnectAgentDialog({ className }: { className?: string }) { - Connect to Claude Code, Codex, OpenCode… + Connect your coding agent to SurfSense Give your coding agent access to SurfSense scrapers and your knowledge base. Create an API key under API Keys, choose your agent, then paste the config. From 38b784fbacb1f7f0a05e2cd2259a0d7963b8c6ff Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:44:49 +0530 Subject: [PATCH 13/34] feat(workspaces): implement workspace limits feature with backend integration and UI updates --- surfsense_backend/app/config/__init__.py | 3 + .../app/routes/workspaces_routes.py | 22 +++++++ .../unit/routes/test_workspaces_limits.py | 63 +++++++++++++++++++ .../atoms/workspaces/workspace-query.atoms.ts | 19 ++++-- .../layout/providers/LayoutDataProvider.tsx | 20 +++++- .../ui/dialogs/CreateWorkspaceDialog.tsx | 2 + .../layout/ui/icon-rail/IconRail.tsx | 31 ++++++--- .../layout/ui/shell/LayoutShell.tsx | 8 +++ .../layout/ui/sidebar/MobileSidebar.tsx | 12 +++- .../contracts/types/workspace.types.ts | 8 +++ .../lib/apis/workspaces-api.service.ts | 5 ++ surfsense_web/lib/query-client/cache-keys.ts | 1 + 12 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 surfsense_backend/tests/unit/routes/test_workspaces_limits.py diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 92e448d9a..830a2a04e 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -850,6 +850,9 @@ class Config: # Auth AUTH_TYPE = os.getenv("AUTH_TYPE", "LOCAL") REGISTRATION_ENABLED = os.getenv("REGISTRATION_ENABLED", "TRUE").upper() == "TRUE" + # Max workspaces a user may own. The frontend reads this through the + # workspace limits route; do not duplicate this value client-side. + MAX_WORKSPACES_PER_USER = int(os.getenv("MAX_WORKSPACES_PER_USER", "100")) # Google OAuth GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID") diff --git a/surfsense_backend/app/routes/workspaces_routes.py b/surfsense_backend/app/routes/workspaces_routes.py index 9dd3f196c..53d4344f7 100644 --- a/surfsense_backend/app/routes/workspaces_routes.py +++ b/surfsense_backend/app/routes/workspaces_routes.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from app.auth.context import AuthContext +from app.config import config from app.db import ( Permission, Workspace, @@ -81,6 +82,22 @@ async def create_workspace( user = auth.user try: workspace_data = workspace.model_dump() + not_deleting = ~Workspace.name.startswith("[DELETING] ") + owned_count = ( + await session.execute( + select(func.count()) + .select_from(Workspace) + .filter(Workspace.user_id == user.id, not_deleting) + ) + ).scalar_one() + if owned_count >= config.MAX_WORKSPACES_PER_USER: + raise HTTPException( + status_code=409, + detail=( + "Workspace limit reached. You can own at most " + f"{config.MAX_WORKSPACES_PER_USER} workspaces." + ), + ) # citations_enabled defaults to True (handled by Pydantic schema) # qna_custom_instructions defaults to None/empty (handled by DB) @@ -207,6 +224,11 @@ async def read_workspaces( ) from e +@router.get("/workspaces/limits") +async def read_workspace_limits(_auth: AuthContext = Depends(allow_any_principal)): + return {"max_workspaces_per_user": config.MAX_WORKSPACES_PER_USER} + + @router.get("/workspaces/{workspace_id}", response_model=WorkspaceRead) async def read_workspace( workspace_id: int, diff --git a/surfsense_backend/tests/unit/routes/test_workspaces_limits.py b/surfsense_backend/tests/unit/routes/test_workspaces_limits.py new file mode 100644 index 000000000..ab89d08bc --- /dev/null +++ b/surfsense_backend/tests/unit/routes/test_workspaces_limits.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from app.routes import workspaces_routes +from app.schemas import WorkspaceCreate + +pytestmark = pytest.mark.unit + + +class _CountResult: + def __init__(self, count: int): + self.count = count + + def scalar_one(self) -> int: + return self.count + + +class _FakeSession: + def __init__(self, owned_count: int): + self.owned_count = owned_count + + async def execute(self, _statement): + return _CountResult(self.owned_count) + + +@pytest.mark.asyncio +async def test_read_workspace_limits_uses_backend_config(monkeypatch): + monkeypatch.setattr( + workspaces_routes.config, + "MAX_WORKSPACES_PER_USER", + 37, + raising=False, + ) + + result = await workspaces_routes.read_workspace_limits(_auth=SimpleNamespace()) + + assert result == {"max_workspaces_per_user": 37} + + +@pytest.mark.asyncio +async def test_create_workspace_rejects_when_owned_limit_reached(monkeypatch): + monkeypatch.setattr( + workspaces_routes.config, + "MAX_WORKSPACES_PER_USER", + 2, + raising=False, + ) + auth = SimpleNamespace(user=SimpleNamespace(id="user-1")) + session = _FakeSession(owned_count=2) + + with pytest.raises(HTTPException) as exc_info: + await workspaces_routes.create_workspace( + WorkspaceCreate(name="Extra", description=""), + session=session, + auth=auth, + ) + + assert exc_info.value.status_code == 409 + assert "at most 2 workspaces" in exc_info.value.detail diff --git a/surfsense_web/atoms/workspaces/workspace-query.atoms.ts b/surfsense_web/atoms/workspaces/workspace-query.atoms.ts index 85203cc1d..09e2aa290 100644 --- a/surfsense_web/atoms/workspaces/workspace-query.atoms.ts +++ b/surfsense_web/atoms/workspaces/workspace-query.atoms.ts @@ -6,14 +6,23 @@ import { cacheKeys } from "@/lib/query-client/cache-keys"; export const activeWorkspaceIdAtom = atom(null); -export const workspacesQueryParamsAtom = atom({ - skip: 0, - limit: 10, - owned_only: false, +export const workspaceLimitsAtom = atomWithQuery(() => { + return { + queryKey: cacheKeys.workspaces.limits, + staleTime: Infinity, + queryFn: async () => { + return workspacesApiService.getWorkspaceLimits(); + }, + }; }); export const workspacesAtom = atomWithQuery((get) => { - const queryParams = get(workspacesQueryParamsAtom); + const workspaceLimits = get(workspaceLimitsAtom).data; + const queryParams: GetWorkspacesRequest["queryParams"] = { + skip: 0, + ...(workspaceLimits ? { limit: workspaceLimits.max_workspaces_per_user } : {}), + owned_only: false, + }; return { queryKey: cacheKeys.workspaces.withQueryParams(queryParams), diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 682d2923e..a61a1a703 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -14,7 +14,7 @@ import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom"; import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms"; -import { workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms"; +import { workspaceLimitsAtom, workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms"; import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog"; import { AnnouncementSpotlight } from "@/components/announcements/AnnouncementSpotlight"; import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog"; @@ -84,6 +84,7 @@ export function LayoutDataProvider({ refetch: refetchWorkspaces, isSuccess: workspacesLoaded, } = useAtomValue(workspacesAtom); + const { data: workspaceLimits } = useAtomValue(workspaceLimitsAtom); const { mutateAsync: deleteWorkspace } = useAtomValue(deleteWorkspaceMutationAtom); const currentThreadState = useAtomValue(currentThreadAtom); const resetCurrentThread = useSetAtom(resetCurrentThreadAtom); @@ -225,6 +226,13 @@ export function LayoutDataProvider({ createdAt: space.created_at, })); }, [workspacesData]); + const maxWorkspacesPerUser = workspaceLimits?.max_workspaces_per_user; + const ownedWorkspaceCount = workspaces.reduce( + (count, space) => count + (space.isOwner ? 1 : 0), + 0 + ); + const isAtWorkspaceLimit = + maxWorkspacesPerUser !== undefined && ownedWorkspaceCount >= maxWorkspacesPerUser; // Find active workspace from list, falling back to the route-scoped detail query. const activeWorkspace: Workspace | null = useMemo(() => { @@ -335,8 +343,14 @@ export function LayoutDataProvider({ ); const handleAddWorkspace = useCallback(() => { + if (isAtWorkspaceLimit) { + toast.error( + `Workspace limit reached. You can own at most ${maxWorkspacesPerUser} workspaces.` + ); + return; + } setIsCreateWorkspaceDialogOpen(true); - }, []); + }, [isAtWorkspaceLimit, maxWorkspacesPerUser]); const setAnnouncementsDialog = useSetAtom(announcementsDialogAtom); @@ -663,6 +677,8 @@ export function LayoutDataProvider({ onWorkspaceDelete={handleWorkspaceDeleteClick} onWorkspaceSettings={handleWorkspaceSettings} onAddWorkspace={handleAddWorkspace} + isAtWorkspaceLimit={isAtWorkspaceLimit} + maxWorkspacesPerUser={maxWorkspacesPerUser} workspace={activeWorkspace} navItems={navItems} onNavItemClick={handleNavItemClick} diff --git a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx index 75192e5e2..d8577fe1d 100644 --- a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx +++ b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { useForm } from "react-hook-form"; +import { toast } from "sonner"; import * as z from "zod"; import { createWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms"; import { Button } from "@/components/ui/button"; @@ -87,6 +88,7 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia ); } catch (error) { console.error("Failed to create workspace:", error); + toast.error(error instanceof Error ? error.message : "Failed to create workspace"); setIsSubmitting(false); } }; diff --git a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx index 1f700c89f..0eeac9ca7 100644 --- a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx +++ b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx @@ -20,6 +20,8 @@ interface IconRailProps { onWorkspaceDelete?: (workspace: Workspace) => void; onWorkspaceSettings?: (workspace: Workspace) => void; onAddWorkspace: () => void; + isAtWorkspaceLimit?: boolean; + maxWorkspacesPerUser?: number; isSingleRailMode?: boolean; onNewChat?: () => void; navItems?: NavItem[]; @@ -42,6 +44,8 @@ export function IconRail({ onWorkspaceDelete, onWorkspaceSettings, onAddWorkspace, + isAtWorkspaceLimit = false, + maxWorkspacesPerUser, isSingleRailMode = false, onNewChat, navItems = [], @@ -78,6 +82,10 @@ export function IconRail({ })), ] : []; + const addWorkspaceLabel = + isAtWorkspaceLimit && maxWorkspacesPerUser !== undefined + ? `Workspace limit reached: ${maxWorkspacesPerUser}` + : "Add workspace"; return (
@@ -99,18 +107,21 @@ export function IconRail({ - + + + - Add workspace + {addWorkspaceLabel} diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 44a2259ee..70bb317dd 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -95,6 +95,8 @@ interface LayoutShellProps { onWorkspaceDelete?: (workspace: Workspace) => void; onWorkspaceSettings?: (workspace: Workspace) => void; onAddWorkspace: () => void; + isAtWorkspaceLimit?: boolean; + maxWorkspacesPerUser?: number; workspace: Workspace | null; navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; @@ -198,6 +200,8 @@ export function LayoutShell({ onWorkspaceDelete, onWorkspaceSettings, onAddWorkspace, + isAtWorkspaceLimit = false, + maxWorkspacesPerUser, workspace, navItems, onNavItemClick, @@ -280,6 +284,8 @@ export function LayoutShell({ activeWorkspaceId={activeWorkspaceId} onWorkspaceSelect={onWorkspaceSelect} onAddWorkspace={onAddWorkspace} + isAtWorkspaceLimit={isAtWorkspaceLimit} + maxWorkspacesPerUser={maxWorkspacesPerUser} workspace={workspace} navItems={navItems} onNavItemClick={onNavItemClick} @@ -352,6 +358,8 @@ export function LayoutShell({ onWorkspaceDelete={onWorkspaceDelete} onWorkspaceSettings={onWorkspaceSettings} onAddWorkspace={onAddWorkspace} + isAtWorkspaceLimit={isAtWorkspaceLimit} + maxWorkspacesPerUser={maxWorkspacesPerUser} isSingleRailMode={false} user={user} onUserSettings={onUserSettings} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index de1b08a6a..c84d39891 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -17,6 +17,8 @@ interface MobileSidebarProps { activeWorkspaceId: number | null; onWorkspaceSelect: (id: number) => void; onAddWorkspace: () => void; + isAtWorkspaceLimit?: boolean; + maxWorkspacesPerUser?: number; workspace: Workspace | null; navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; @@ -67,6 +69,8 @@ export function MobileSidebar({ onWorkspaceSelect, onAddWorkspace, + isAtWorkspaceLimit = false, + maxWorkspacesPerUser, workspace, navItems, onNavItemClick, @@ -107,6 +111,10 @@ export function MobileSidebar({ onChatSelect(chat); onOpenChange(false); }; + const addWorkspaceLabel = + isAtWorkspaceLimit && maxWorkspacesPerUser !== undefined + ? `Workspace limit reached: ${maxWorkspacesPerUser}` + : "Add workspace"; return ( @@ -136,10 +144,12 @@ export function MobileSidebar({ variant="ghost" size="icon" onClick={onAddWorkspace} + disabled={isAtWorkspaceLimit} + title={addWorkspaceLabel} className="h-10 w-10 shrink-0 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50" > - Add workspace + {addWorkspaceLabel}
diff --git a/surfsense_web/contracts/types/workspace.types.ts b/surfsense_web/contracts/types/workspace.types.ts index a777f810a..8a9502131 100644 --- a/surfsense_web/contracts/types/workspace.types.ts +++ b/surfsense_web/contracts/types/workspace.types.ts @@ -29,6 +29,13 @@ export const getWorkspacesRequest = z.object({ export const getWorkspacesResponse = z.array(workspace); +/** + * Workspace limits + */ +export const workspaceLimits = z.object({ + max_workspaces_per_user: z.number(), +}); + /** * Create workspace */ @@ -94,6 +101,7 @@ export const leaveWorkspaceResponse = z.object({ // Inferred types export type Workspace = z.infer; +export type WorkspaceLimits = z.infer; export type GetWorkspacesRequest = z.infer; export type GetWorkspacesResponse = z.infer; export type CreateWorkspaceRequest = z.infer; diff --git a/surfsense_web/lib/apis/workspaces-api.service.ts b/surfsense_web/lib/apis/workspaces-api.service.ts index 8e1494e00..0bd0370a5 100644 --- a/surfsense_web/lib/apis/workspaces-api.service.ts +++ b/surfsense_web/lib/apis/workspaces-api.service.ts @@ -19,11 +19,16 @@ import { updateWorkspaceApiAccessResponse, updateWorkspaceRequest, updateWorkspaceResponse, + workspaceLimits, } from "@/contracts/types/workspace.types"; import { ValidationError } from "../error"; import { baseApiService } from "./base-api.service"; class WorkspacesApiService { + getWorkspaceLimits = async () => { + return baseApiService.get(`/api/v1/workspaces/limits`, workspaceLimits); + }; + /** * Get a list of workspaces with optional filtering and pagination */ diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 13c141f69..9fc2ef2cb 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -49,6 +49,7 @@ export const cacheKeys = { }, workspaces: { all: ["workspaces"] as const, + limits: ["workspaces", "limits"] as const, withQueryParams: (queries: GetWorkspacesRequest["queryParams"]) => ["workspaces", ...stableEntries(queries)] as const, detail: (workspaceId: string) => ["workspaces", workspaceId] as const, From 1b62352d0da7b74d451b146b328e0d91d05e2f4c Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:55:22 +0530 Subject: [PATCH 14/34] refactor(document-mention-picker): replace MessageSquare icon with MessageCircleMore for improved visual consistency --- .../components/new-chat/document-mention-picker.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index dca3b5f7f..17a24f799 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -8,7 +8,7 @@ import { ChevronRight, Files, Folder as FolderIcon, - MessageSquare, + MessageCircleMore, Unplug, } from "lucide-react"; import { @@ -146,7 +146,7 @@ export function promoteRecentMention(workspaceId: number, mention: MentionedDocu function getMentionIcon(mention: MentionedDocumentInfo) { if (mention.kind === "folder") return ; - if (mention.kind === "thread") return ; + if (mention.kind === "thread") return ; if (mention.kind === "connector") { return getConnectorIcon(mention.connector_type, "size-4") ?? ; } @@ -517,7 +517,7 @@ export const DocumentMentionPicker = forwardRef< id: "chats", label: "Chats", subtitle: "Reference another conversation", - icon: , + icon: , type: "branch", value: { kind: "view", view: { kind: "chats" } }, }); @@ -565,7 +565,7 @@ export const DocumentMentionPicker = forwardRef< id: getMentionDocKey(mention), label: mention.title, subtitle: "Chat", - icon: , + icon: , type: "item" as const, disabled: selectedKeys.has(getMentionDocKey(mention)), value: { kind: "mention" as const, mention }, @@ -625,7 +625,7 @@ export const DocumentMentionPicker = forwardRef< id: getMentionDocKey(mention), label: mention.title, subtitle: "Chat", - icon: , + icon: , type: "item" as const, disabled: selectedKeys.has(getMentionDocKey(mention)), value: { kind: "mention" as const, mention }, From 0ca696f3fca25a1bf021882b57ccf416f4de465f Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:58:18 +0530 Subject: [PATCH 15/34] refactor(tabs): remove legacy tab migration functionality and associated tests for codebase cleanup --- surfsense_web/atoms/tabs/migrate-tabs.test.ts | 21 ------------------- surfsense_web/atoms/tabs/migrate-tabs.ts | 19 ----------------- 2 files changed, 40 deletions(-) delete mode 100644 surfsense_web/atoms/tabs/migrate-tabs.test.ts delete mode 100644 surfsense_web/atoms/tabs/migrate-tabs.ts diff --git a/surfsense_web/atoms/tabs/migrate-tabs.test.ts b/surfsense_web/atoms/tabs/migrate-tabs.test.ts deleted file mode 100644 index e0067a4d2..000000000 --- a/surfsense_web/atoms/tabs/migrate-tabs.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { migrateLegacyTabs } from "./migrate-tabs"; - -// Run with: pnpm exec tsx --test atoms/tabs/migrate-tabs.test.ts -test("maps legacy searchSpaceId to workspaceId on read", () => { - const migrated = migrateLegacyTabs({ - tabs: [{ id: "chat-new", type: "chat", searchSpaceId: 7 } as never], - activeTabId: "chat-new", - }); - const tab = migrated.tabs[0] as { workspaceId?: number }; - assert.equal(tab.workspaceId, 7); -}); - -test("leaves an already-migrated workspaceId untouched", () => { - const migrated = migrateLegacyTabs({ - tabs: [{ id: "d1", type: "document", workspaceId: 3, searchSpaceId: 9 } as never], - }); - const tab = migrated.tabs[0] as { workspaceId?: number }; - assert.equal(tab.workspaceId, 3); -}); diff --git a/surfsense_web/atoms/tabs/migrate-tabs.ts b/surfsense_web/atoms/tabs/migrate-tabs.ts deleted file mode 100644 index e95a921de..000000000 --- a/surfsense_web/atoms/tabs/migrate-tabs.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * One-time read-migration for persisted tabs: legacy state stored the workspace - * as `searchSpaceId`. Map it to `workspaceId` on read so already-open tabs keep - * their workspace association after the rename. Pure + dependency-free so it can - * be unit-checked without loading the atom module. - */ -export function migrateLegacyTabs }>( - state: T -): T { - return { - ...state, - tabs: state.tabs.map((t) => { - const legacy = t as { workspaceId?: number; searchSpaceId?: number }; - return legacy.workspaceId === undefined && legacy.searchSpaceId !== undefined - ? { ...t, workspaceId: legacy.searchSpaceId } - : t; - }), - }; -} From 2c4ebaeb7f414e7fa1f5b506ba6f775952fe7afc Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:09:50 +0530 Subject: [PATCH 16/34] feat(zero): publish thread title, visibility, created_by columns --- .../175_publish_thread_metadata_to_zero.py | 23 +++++++++++++++++++ surfsense_backend/app/zero_publication.py | 3 +++ 2 files changed, 26 insertions(+) create mode 100644 surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py diff --git a/surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py b/surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py new file mode 100644 index 000000000..d4d298a4a --- /dev/null +++ b/surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py @@ -0,0 +1,23 @@ +"""publish thread metadata to zero_publication + +Revision ID: 175 +Revises: 174 +""" + +from collections.abc import Sequence + +from alembic import op +from app.zero_publication import apply_publication + +revision: str = "175" +down_revision: str | None = "174" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + apply_publication(op.get_bind()) + + +def downgrade() -> None: + """No-op. Historical publication shapes are immutable.""" diff --git a/surfsense_backend/app/zero_publication.py b/surfsense_backend/app/zero_publication.py index c44a29dcd..f6dff6cdf 100644 --- a/surfsense_backend/app/zero_publication.py +++ b/surfsense_backend/app/zero_publication.py @@ -60,6 +60,9 @@ AUTOMATION_COLS = [ NEW_CHAT_THREAD_COLS = [ "id", "workspace_id", + "title", + "visibility", + "created_by_id", ] # Enough to drive the lifecycle UI by push: status, the reviewable brief, and From c0492be3ef8d8c8695a6adc69e3c7a04a9a18532 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:09:56 +0530 Subject: [PATCH 17/34] feat(zero): mirror thread title/visibility/createdById in schema --- surfsense_web/zero/schema/chat.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/surfsense_web/zero/schema/chat.ts b/surfsense_web/zero/schema/chat.ts index d42a11848..81673feaa 100644 --- a/surfsense_web/zero/schema/chat.ts +++ b/surfsense_web/zero/schema/chat.ts @@ -24,6 +24,9 @@ export const newChatThreadTable = table("new_chat_threads") .columns({ id: number(), workspaceId: number().from("workspace_id"), + title: string(), + visibility: string(), + createdById: string().optional().from("created_by_id"), }) .primaryKey("id"); From c49a3d765d78572a1fe55e80545c0e90fca2a8ed Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:10:31 +0530 Subject: [PATCH 18/34] feat(zero): add threads.byIds query with per-thread authz --- surfsense_web/zero/queries/chat.ts | 10 ++++++++++ surfsense_web/zero/queries/index.ts | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/surfsense_web/zero/queries/chat.ts b/surfsense_web/zero/queries/chat.ts index 40e09a6ee..06969a4e9 100644 --- a/surfsense_web/zero/queries/chat.ts +++ b/surfsense_web/zero/queries/chat.ts @@ -29,3 +29,13 @@ export const chatSessionQueries = { .one() ), }; + +export const threadQueries = { + byIds: defineQuery(z.object({ ids: z.array(z.number()) }), ({ args: { ids }, ctx }) => + constrainToAllowedSpaces(zql.new_chat_threads, ctx) + .where("id", "IN", ids) + .where(({ or, cmp }) => + or(cmp("createdById", ctx?.userId ?? ""), cmp("visibility", "SEARCH_SPACE")) + ) + ), +}; diff --git a/surfsense_web/zero/queries/index.ts b/surfsense_web/zero/queries/index.ts index 45df8fa98..2e51bf9a8 100644 --- a/surfsense_web/zero/queries/index.ts +++ b/surfsense_web/zero/queries/index.ts @@ -1,6 +1,6 @@ import { defineQueries } from "@rocicorp/zero"; import { automationRunQueries } from "./automations"; -import { chatSessionQueries, commentQueries, messageQueries } from "./chat"; +import { chatSessionQueries, commentQueries, messageQueries, threadQueries } from "./chat"; import { connectorQueries, documentQueries } from "./documents"; import { folderQueries } from "./folders"; import { notificationQueries } from "./inbox"; @@ -15,6 +15,7 @@ export const queries = defineQueries({ messages: messageQueries, comments: commentQueries, chatSession: chatSessionQueries, + threads: threadQueries, user: userQueries, automationRuns: automationRunQueries, podcasts: podcastQueries, From 88b9632911ee7a85c2b91bd938854f190eaeed21 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:10:39 +0530 Subject: [PATCH 19/34] feat(zero): add documents.byIds query for document tabs --- surfsense_web/zero/queries/documents.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/surfsense_web/zero/queries/documents.ts b/surfsense_web/zero/queries/documents.ts index d2f483989..6c110aee2 100644 --- a/surfsense_web/zero/queries/documents.ts +++ b/surfsense_web/zero/queries/documents.ts @@ -9,6 +9,9 @@ export const documentQueries = { if (!canReadSpace(ctx, workspaceId)) return denySpace(query).orderBy("createdAt", "desc"); return constrainToAllowedSpaces(query, ctx).orderBy("createdAt", "desc"); }), + byIds: defineQuery(z.object({ ids: z.array(z.number()) }), ({ args: { ids }, ctx }) => + constrainToAllowedSpaces(zql.documents, ctx).where("id", "IN", ids) + ), }; export const connectorQueries = { From a8fb161bef97f6e5fc658ebb7a20308abd7d0c20 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:10:47 +0530 Subject: [PATCH 20/34] refactor(tabs): slim Tab to pointer-only state on v2 storage key --- surfsense_web/atoms/tabs/tabs.atom.ts | 137 ++++++++++++-------------- 1 file changed, 61 insertions(+), 76 deletions(-) diff --git a/surfsense_web/atoms/tabs/tabs.atom.ts b/surfsense_web/atoms/tabs/tabs.atom.ts index 45e0c098c..fbc546c42 100644 --- a/surfsense_web/atoms/tabs/tabs.atom.ts +++ b/surfsense_web/atoms/tabs/tabs.atom.ts @@ -1,22 +1,13 @@ import { atom } from "jotai"; import { atomWithStorage, createJSONStorage } from "jotai/utils"; -import type { ChatVisibility } from "@/lib/chat/thread-persistence"; -import { migrateLegacyTabs } from "./migrate-tabs"; export type TabType = "chat" | "document"; export interface Tab { id: string; type: TabType; - title: string; - /** For chat tabs */ - chatId?: number | null; - chatUrl?: string; - visibility?: ChatVisibility; - hasComments?: boolean; - /** For document tabs */ - documentId?: number; - workspaceId?: number; + entityId: number | null; + workspaceId: number; } interface TabsState { @@ -27,9 +18,8 @@ interface TabsState { const INITIAL_CHAT_TAB: Tab = { id: "chat-new", type: "chat", - title: "New Chat", - chatId: null, - chatUrl: undefined, + entityId: null, + workspaceId: 0, }; const initialState: TabsState = { @@ -37,23 +27,14 @@ const initialState: TabsState = { activeTabId: "chat-new", }; -// Prevent race conditions where route-sync recreates a just-deleted chat tab. -const deletedChatIdsAtom = atom>(new Set()); - // Persist tabs in localStorage so they survive a hard refresh and let the user // keep tabs open across multiple workspaces (browser-like behavior). const localStorageAdapter = createJSONStorage( () => (typeof window !== "undefined" ? localStorage : undefined) as Storage ); -// Wrap getItem in place so the adapter keeps its original (sync) type while -// migrating legacy persisted state on read. -const baseGetItem = localStorageAdapter.getItem.bind(localStorageAdapter); -localStorageAdapter.getItem = (key, initialValue) => - migrateLegacyTabs(baseGetItem(key, initialValue)); - export const tabsStateAtom = atomWithStorage( - "surfsense:tabs", + "surfsense:tabs:v2", initialState, localStorageAdapter, { getOnInit: true } @@ -66,11 +47,11 @@ export const activeTabAtom = atom((get) => { return state.tabs.find((t) => t.id === state.activeTabId) ?? null; }); -function makeChatTabId(chatId: number | null): string { +export function makeChatTabId(chatId: number | null): string { return chatId ? `chat-${chatId}` : "chat-new"; } -function makeDocumentTabId(documentId: number): string { +export function makeDocumentTabId(documentId: number): string { return `doc-${documentId}`; } @@ -86,24 +67,12 @@ export const syncChatTabAtom = atom( set, { chatId, - title, - chatUrl, workspaceId, - visibility, - hasComments, }: { chatId: number | null; - title?: string; - chatUrl?: string; workspaceId: number; - visibility?: ChatVisibility; - hasComments?: boolean; } ) => { - if (chatId && get(deletedChatIdsAtom).has(chatId)) { - return; - } - const state = get(tabsStateAtom); const tabId = makeChatTabId(chatId); const existing = state.tabs.find((t) => t.id === tabId); @@ -116,11 +85,8 @@ export const syncChatTabAtom = atom( t.id === tabId ? { ...t, - title: title || t.title, - chatUrl: chatUrl || t.chatUrl, - workspaceId: workspaceId ?? t.workspaceId, - ...(visibility !== undefined ? { visibility } : {}), - ...(hasComments !== undefined ? { hasComments } : {}), + entityId: chatId, + workspaceId, } : t ), @@ -136,11 +102,13 @@ export const syncChatTabAtom = atom( set(tabsStateAtom, { ...state, activeTabId: "chat-new", - tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, workspaceId, chatUrl } : t)), + tabs: state.tabs.map((t) => + t.id === "chat-new" ? { ...t, entityId: null, workspaceId } : t + ), }); } else { set(tabsStateAtom, { - tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, workspaceId, chatUrl }], + tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, workspaceId }], activeTabId: "chat-new", }); } @@ -152,12 +120,8 @@ export const syncChatTabAtom = atom( const newTab: Tab = { id: tabId, type: "chat", - title: title || "New Chat", - chatId, - chatUrl, + entityId: chatId, workspaceId, - ...(visibility !== undefined ? { visibility } : {}), - ...(hasComments !== undefined ? { hasComments } : {}), }; let updatedTabs: Tab[]; @@ -172,29 +136,26 @@ export const syncChatTabAtom = atom( } ); -/** Update the title of the current chat tab (e.g., when a chat gets its first response). */ +/** Promote the lazy "new chat" tab once the server creates its thread. */ export const updateChatTabTitleAtom = atom( null, - (get, set, { chatId, title }: { chatId: number; title: string }) => { + (get, set, { chatId }: { chatId: number; title?: string }) => { const state = get(tabsStateAtom); const tabId = makeChatTabId(chatId); const hasExactTab = state.tabs.some((t) => t.id === tabId); - // During lazy thread creation, title updates can arrive before "chat-new" - // is swapped to chat-{id}. In that case, promote the active "chat-new" tab. + // During lazy thread creation, title updates can arrive before "chat-new" is + // swapped to chat-{id}. In that case, promote the pointer only; title comes + // from Zero. if (!hasExactTab && state.activeTabId === "chat-new") { set(tabsStateAtom, { ...state, activeTabId: tabId, - tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t)), + tabs: state.tabs.map((t) => + t.id === "chat-new" ? { ...t, id: tabId, entityId: chatId } : t + ), }); - return; } - - set(tabsStateAtom, { - ...state, - tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title } : t)), - }); } ); @@ -204,7 +165,7 @@ export const openDocumentTabAtom = atom( ( get, set, - { documentId, workspaceId, title }: { documentId: number; workspaceId: number; title?: string } + { documentId, workspaceId }: { documentId: number; workspaceId: number; title?: string } ) => { const state = get(tabsStateAtom); const tabId = makeDocumentTabId(documentId); @@ -214,7 +175,7 @@ export const openDocumentTabAtom = atom( set(tabsStateAtom, { ...state, activeTabId: tabId, - tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title: title || t.title } : t)), + tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, workspaceId } : t)), }); return; } @@ -222,8 +183,7 @@ export const openDocumentTabAtom = atom( const newTab: Tab = { id: tabId, type: "document", - title: title || `Document ${documentId}`, - documentId, + entityId: documentId, workspaceId, }; @@ -254,11 +214,12 @@ export const closeTabAtom = atom(null, (get, set, tabId: string) => { // Don't close the last tab — always keep at least one if (remaining.length === 0) { + const closedTab = state.tabs[idx]; set(tabsStateAtom, { - tabs: [INITIAL_CHAT_TAB], + tabs: [{ ...INITIAL_CHAT_TAB, workspaceId: closedTab.workspaceId }], activeTabId: "chat-new", }); - return INITIAL_CHAT_TAB; + return { ...INITIAL_CHAT_TAB, workspaceId: closedTab.workspaceId }; } let newActiveId = state.activeTabId; @@ -279,18 +240,16 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => { const idx = state.tabs.findIndex((t) => t.id === tabId); if (idx === -1) return null; - const deletedChatIds = get(deletedChatIdsAtom); - set(deletedChatIdsAtom, new Set([...deletedChatIds, chatId])); - const remaining = state.tabs.filter((t) => t.id !== tabId); // Always keep at least one tab available. if (remaining.length === 0) { + const removedTab = state.tabs[idx]; set(tabsStateAtom, { - tabs: [INITIAL_CHAT_TAB], + tabs: [{ ...INITIAL_CHAT_TAB, workspaceId: removedTab.workspaceId }], activeTabId: "chat-new", }); - return INITIAL_CHAT_TAB; + return { ...INITIAL_CHAT_TAB, workspaceId: removedTab.workspaceId }; } let newActiveId = state.activeTabId; @@ -303,8 +262,34 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => { return remaining.find((t) => t.id === newActiveId) ?? null; }); -/** Reset tabs when switching workspaces. */ -export const resetTabsAtom = atom(null, (_get, set) => { - set(tabsStateAtom, { ...initialState }); - set(deletedChatIdsAtom, new Set()); +/** Remove unresolved chat pointers after Zero confirms the queried rows are complete. */ +export const pruneMissingChatTabsAtom = atom(null, (get, set, missingChatIds: Set) => { + if (missingChatIds.size === 0) return; + + const state = get(tabsStateAtom); + const firstMissingIdx = state.tabs.findIndex( + (t) => t.type === "chat" && t.entityId !== null && missingChatIds.has(t.entityId) + ); + if (firstMissingIdx === -1) return; + + const remaining = state.tabs.filter( + (t) => !(t.type === "chat" && t.entityId !== null && missingChatIds.has(t.entityId)) + ); + + if (remaining.length === 0) { + set(tabsStateAtom, { + tabs: [INITIAL_CHAT_TAB], + activeTabId: "chat-new", + }); + return; + } + + const activeWasPruned = state.tabs.some( + (t) => t.id === state.activeTabId && t.type === "chat" && t.entityId !== null && missingChatIds.has(t.entityId) + ); + const newActiveId = activeWasPruned + ? remaining[Math.min(firstMissingIdx, remaining.length - 1)].id + : state.activeTabId; + + set(tabsStateAtom, { tabs: remaining, activeTabId: newActiveId }); }); From 873ec0bcd789641ce787f0051695030e289ab827 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:10:53 +0530 Subject: [PATCH 21/34] feat(tabs): add useResolvedTabs join hook resolving titles from zero --- surfsense_web/hooks/use-resolved-tabs.test.ts | 53 +++++++ surfsense_web/hooks/use-resolved-tabs.ts | 129 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 surfsense_web/hooks/use-resolved-tabs.test.ts create mode 100644 surfsense_web/hooks/use-resolved-tabs.ts diff --git a/surfsense_web/hooks/use-resolved-tabs.test.ts b/surfsense_web/hooks/use-resolved-tabs.test.ts new file mode 100644 index 000000000..34a51df86 --- /dev/null +++ b/surfsense_web/hooks/use-resolved-tabs.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + getMissingCompleteChatIds, + resolveTabPointers, + type ResolvedTab, +} from "./use-resolved-tabs"; + +// Run with: pnpm exec tsx --test hooks/use-resolved-tabs.test.ts +test("does not prune unresolved chat tabs before Zero completes", () => { + const missing = getMissingCompleteChatIds({ + tabs: [{ id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 }], + threadRows: [], + resultType: "unknown", + }); + + assert.equal(missing.size, 0); +}); + +test("prunes unresolved chat tabs only after Zero completes", () => { + const missing = getMissingCompleteChatIds({ + tabs: [ + { id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 }, + { id: "chat-43", type: "chat", entityId: 43, workspaceId: 7 }, + ], + threadRows: [{ id: 43, title: "Present", visibility: "PRIVATE" }], + resultType: "complete", + }); + + assert.deepEqual([...missing], [42]); +}); + +test("merges pointer tabs with synced row titles", () => { + const resolved = resolveTabPointers({ + tabs: [ + { id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 }, + { id: "doc-9", type: "document", entityId: 9, workspaceId: 7 }, + ], + threadRows: [{ id: 42, title: "Live chat title", visibility: "SEARCH_SPACE" }], + documentRows: [{ id: 9, title: "Live document title" }], + }); + + assert.deepEqual( + resolved.map((tab): Pick => ({ + id: tab.id, + title: tab.title, + })), + [ + { id: "chat-42", title: "Live chat title" }, + { id: "doc-9", title: "Live document title" }, + ] + ); +}); diff --git a/surfsense_web/hooks/use-resolved-tabs.ts b/surfsense_web/hooks/use-resolved-tabs.ts new file mode 100644 index 000000000..6b321cbd3 --- /dev/null +++ b/surfsense_web/hooks/use-resolved-tabs.ts @@ -0,0 +1,129 @@ +"use client"; + +import { useQuery } from "@rocicorp/zero/react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useEffect, useMemo } from "react"; +import { pruneMissingChatTabsAtom, type Tab, tabsAtom } from "@/atoms/tabs/tabs.atom"; +import type { ChatVisibility } from "@/lib/chat/thread-persistence"; +import { queries } from "@/zero/queries"; + +interface ThreadRow { + id: number; + title: string; + visibility: string; +} + +interface DocumentRow { + id: number; + title: string; +} + +export interface ResolvedTab extends Tab { + title: string; + chatUrl?: string; + visibility?: ChatVisibility; +} + +function uniqueEntityIds(tabs: Tab[], type: Tab["type"]): number[] { + const ids = new Set(); + for (const tab of tabs) { + if (tab.type === type && tab.entityId !== null) { + ids.add(tab.entityId); + } + } + return [...ids]; +} + +function rowById(rows: readonly T[] | undefined): Map { + return new Map((rows ?? []).map((row) => [row.id, row])); +} + +export function getChatUrl(workspaceId: number, threadId: number | null): string { + return threadId + ? `/dashboard/${workspaceId}/new-chat/${threadId}` + : `/dashboard/${workspaceId}/new-chat`; +} + +export function resolveTabPointers({ + tabs, + threadRows, + documentRows, +}: { + tabs: Tab[]; + threadRows?: readonly ThreadRow[]; + documentRows?: readonly DocumentRow[]; +}): ResolvedTab[] { + const threads = rowById(threadRows); + const documents = rowById(documentRows); + + return tabs.map((tab) => { + if (tab.type === "document") { + const title = + tab.entityId === null ? "Document" : (documents.get(tab.entityId)?.title ?? `Document ${tab.entityId}`); + return { ...tab, title }; + } + + const row = tab.entityId === null ? undefined : threads.get(tab.entityId); + return { + ...tab, + title: row?.title || (tab.entityId === null ? "New Chat" : `Chat ${tab.entityId}`), + chatUrl: getChatUrl(tab.workspaceId, tab.entityId), + ...(row?.visibility !== undefined ? { visibility: row.visibility as ChatVisibility } : {}), + }; + }); +} + +export function getMissingCompleteChatIds({ + tabs, + threadRows, + resultType, +}: { + tabs: Tab[]; + threadRows?: readonly ThreadRow[]; + resultType: string; +}): Set { + if (resultType !== "complete") return new Set(); + + const threadIds = new Set((threadRows ?? []).map((row) => row.id)); + return new Set( + tabs + .filter( + (tab): tab is Tab & { type: "chat"; entityId: number } => + tab.type === "chat" && tab.entityId !== null && !threadIds.has(tab.entityId) + ) + .map((tab) => tab.entityId) + ); +} + +export function useResolvedTabs(): ResolvedTab[] { + const tabs = useAtomValue(tabsAtom); + const pruneMissingChatTabs = useSetAtom(pruneMissingChatTabsAtom); + + const chatIds = useMemo(() => uniqueEntityIds(tabs, "chat"), [tabs]); + const documentIds = useMemo(() => uniqueEntityIds(tabs, "document"), [tabs]); + const [threadRows, threadResult] = useQuery( + queries.threads.byIds({ ids: chatIds.length > 0 ? chatIds : [-1] }) + ); + const [documentRows] = useQuery( + queries.documents.byIds({ ids: documentIds.length > 0 ? documentIds : [-1] }) + ); + + const missingChatIds = useMemo( + () => + getMissingCompleteChatIds({ + tabs, + threadRows, + resultType: threadResult.type, + }), + [tabs, threadRows, threadResult.type] + ); + + useEffect(() => { + pruneMissingChatTabs(missingChatIds); + }, [missingChatIds, pruneMissingChatTabs]); + + return useMemo( + () => resolveTabPointers({ tabs, threadRows, documentRows }), + [tabs, threadRows, documentRows] + ); +} From e3884399201024200b077396b8f5d29cfd0629ec Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:11:05 +0530 Subject: [PATCH 22/34] refactor(tabs): render TabBar from resolved tabs --- .../components/layout/ui/tabs/TabBar.tsx | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/surfsense_web/components/layout/ui/tabs/TabBar.tsx b/surfsense_web/components/layout/ui/tabs/TabBar.tsx index 869c9cee2..fc92643b8 100644 --- a/surfsense_web/components/layout/ui/tabs/TabBar.tsx +++ b/surfsense_web/components/layout/ui/tabs/TabBar.tsx @@ -3,19 +3,14 @@ import { useAtomValue, useSetAtom } from "jotai"; import { Plus, X } from "lucide-react"; import { Fragment, useCallback, useEffect, useRef, useState } from "react"; -import { - activeTabIdAtom, - closeTabAtom, - switchTabAtom, - type Tab, - tabsAtom, -} from "@/atoms/tabs/tabs.atom"; +import { activeTabIdAtom, closeTabAtom, switchTabAtom } from "@/atoms/tabs/tabs.atom"; import { Button } from "@/components/ui/button"; +import { getChatUrl, type ResolvedTab, useResolvedTabs } from "@/hooks/use-resolved-tabs"; import { cn } from "@/lib/utils"; interface TabBarProps { - onTabSwitch?: (tab: Tab) => void; - onTabPrefetch?: (tab: Tab) => void; + onTabSwitch?: (tab: ResolvedTab) => void; + onTabPrefetch?: (tab: ResolvedTab) => void; onNewChat?: () => void; leftActions?: React.ReactNode; rightActions?: React.ReactNode; @@ -43,7 +38,7 @@ export function TabBar({ rightActions, className, }: TabBarProps) { - const tabs = useAtomValue(tabsAtom); + const tabs = useResolvedTabs(); const activeTabId = useAtomValue(activeTabIdAtom); const switchTab = useSetAtom(switchTabAtom); const closeTab = useSetAtom(closeTabAtom); @@ -65,7 +60,7 @@ export function TabBar({ ); const handleTabClick = useCallback( - (tab: Tab) => { + (tab: ResolvedTab) => { if (tab.id === activeTabId) return; switchTab(tab.id); onTabSwitch?.(tab); @@ -74,7 +69,7 @@ export function TabBar({ ); const handleTabPrefetch = useCallback( - (tab: Tab) => { + (tab: ResolvedTab) => { if (tab.type === "chat") { onTabPrefetch?.(tab); } @@ -87,10 +82,21 @@ export function TabBar({ e.stopPropagation(); const fallback = closeTab(tabId); if (fallback) { - onTabSwitch?.(fallback); + const resolvedFallback = + tabs.find((tab) => tab.id === fallback.id) ?? + (fallback.type === "chat" + ? { + ...fallback, + title: "New Chat", + chatUrl: getChatUrl(fallback.workspaceId, fallback.entityId), + } + : undefined); + if (resolvedFallback) { + onTabSwitch?.(resolvedFallback); + } } }, - [closeTab, onTabSwitch] + [closeTab, onTabSwitch, tabs] ); // React to tab list growth via a MutationObserver so the scroll catches the From fa21566cdfa47619540026d94767b6f9c788de86 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:11:18 +0530 Subject: [PATCH 23/34] refactor(tabs): resolve document tab via entityId and live title --- .../layout/ui/shell/LayoutShell.tsx | 88 +++++++++++++++++-- 1 file changed, 79 insertions(+), 9 deletions(-) diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 70bb317dd..42ef141b2 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -3,10 +3,11 @@ import { useAtomValue } from "jotai"; import dynamic from "next/dynamic"; import { useMemo, useState } from "react"; -import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; +import { activeTabIdAtom } from "@/atoms/tabs/tabs.atom"; import { Logo } from "@/components/Logo"; import { Spinner } from "@/components/ui/spinner"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { type ResolvedTab, useResolvedTabs } from "@/hooks/use-resolved-tabs"; import { useIsMobile } from "@/hooks/use-mobile"; import { useElectronAPI } from "@/hooks/use-platform"; import { cn } from "@/lib/utils"; @@ -123,6 +124,7 @@ interface LayoutShellProps { defaultCollapsed?: boolean; isChatPage?: boolean; isAllChatsPage?: boolean; + showTabs?: boolean; useWorkspacePanel?: boolean; workspacePanelViewportClassName?: string; workspacePanelContentClassName?: string; @@ -130,8 +132,8 @@ interface LayoutShellProps { className?: string; notifications?: NotificationsDropdownData; isLoadingChats?: boolean; - onTabSwitch?: (tab: Tab) => void; - onTabPrefetch?: (tab: Tab) => void; + onTabSwitch?: (tab: ResolvedTab) => void; + onTabPrefetch?: (tab: ResolvedTab) => void; playgroundSidebar?: React.ReactNode; initialPlaygroundSidebarCollapsed?: boolean; } @@ -141,19 +143,85 @@ function MainContentPanel({ onTabSwitch, onTabPrefetch, onNewChat, + showTabs = true, showRightPanelExpandButton = true, showTopBorder = false, children, }: { isChatPage: boolean; - onTabSwitch?: (tab: Tab) => void; - onTabPrefetch?: (tab: Tab) => void; + onTabSwitch?: (tab: ResolvedTab) => void; + onTabPrefetch?: (tab: ResolvedTab) => void; onNewChat?: () => void; + showTabs?: boolean; showRightPanelExpandButton?: boolean; showTopBorder?: boolean; children: React.ReactNode; }) { - const activeTab = useAtomValue(activeTabAtom); + if (!showTabs) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +function UntabbedMainContentPanel({ + isChatPage, + showTopBorder, + children, +}: { + isChatPage: boolean; + showTopBorder: boolean; + children: React.ReactNode; +}) { + return ( +
+
+
+
+ {children} +
+
+
+ ); +} + +function TabbedMainContentPanel({ + isChatPage, + onTabSwitch, + onTabPrefetch, + onNewChat, + showRightPanelExpandButton, + showTopBorder, + children, +}: { + isChatPage: boolean; + onTabSwitch?: (tab: ResolvedTab) => void; + onTabPrefetch?: (tab: ResolvedTab) => void; + onNewChat?: () => void; + showRightPanelExpandButton: boolean; + showTopBorder: boolean; + children: React.ReactNode; +}) { + const activeTabId = useAtomValue(activeTabIdAtom); + const tabs = useResolvedTabs(); + const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null; const isDocumentTab = activeTab?.type === "document"; return ( @@ -170,11 +238,11 @@ function MainContentPanel({
- {isDocumentTab && activeTab.documentId && activeTab.workspaceId ? ( + {isDocumentTab && activeTab.entityId && activeTab.workspaceId ? (
@@ -228,6 +296,7 @@ export function LayoutShell({ defaultCollapsed = false, isChatPage = false, isAllChatsPage = false, + showTabs = true, useWorkspacePanel = false, workspacePanelViewportClassName, workspacePanelContentClassName, @@ -476,6 +545,7 @@ export function LayoutShell({ onTabSwitch={onTabSwitch} onTabPrefetch={onTabPrefetch} onNewChat={onNewChat} + showTabs={showTabs} showRightPanelExpandButton={!isMacDesktop} showTopBorder={isMacDesktop} > From 1465b907db48d12a3ee79d1b74a1bcd975a9fb03 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:11:23 +0530 Subject: [PATCH 24/34] refactor(tabs): rework fallback navigation for pointer tabs --- .../providers/FreeLayoutDataProvider.tsx | 1 + .../layout/providers/LayoutDataProvider.tsx | 41 +++++++------------ .../layout/ui/sidebar/AllChatsSidebar.tsx | 21 +++------- 3 files changed, 21 insertions(+), 42 deletions(-) diff --git a/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx b/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx index 059872bac..85b5227fa 100644 --- a/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx @@ -85,6 +85,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps onLogout={() => router.push("/register")} pageUsage={pageUsage} isChatPage + showTabs={false} isLoadingChats={false} > {children} diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index a61a1a703..6858794ca 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -11,7 +11,7 @@ import { toast } from "sonner"; import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom"; import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom"; -import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; +import { removeChatTabAtom, syncChatTabAtom } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms"; import { workspaceLimitsAtom, workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms"; @@ -43,6 +43,7 @@ import { Spinner } from "@/components/ui/spinner"; import { useActivateChatThread } from "@/hooks/use-activate-chat-thread"; import { useAnnouncements } from "@/hooks/use-announcements"; import { useInbox } from "@/hooks/use-inbox"; +import { getChatUrl, type ResolvedTab } from "@/hooks/use-resolved-tabs"; import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations"; import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import { workspacesApiService } from "@/lib/apis/workspaces-api.service"; @@ -273,19 +274,11 @@ export function LayoutDataProvider({ // Sync current chat route with tab state useEffect(() => { const chatId = currentChatId ?? null; - const chatUrl = chatId - ? `/dashboard/${workspaceId}/new-chat/${chatId}` - : `/dashboard/${workspaceId}/new-chat`; - const thread = threadsData?.threads?.find((t) => t.id === chatId); syncChatTab({ chatId, - // Avoid overwriting live SSE-updated tab titles with fallback values. - title: chatId ? (thread?.title ?? undefined) : "New Chat", - chatUrl, workspaceId: Number(workspaceId), - ...(thread?.visibility !== undefined ? { visibility: thread.visibility } : {}), }); - }, [currentChatId, workspaceId, threadsData?.threads, syncChatTab]); + }, [currentChatId, workspaceId, syncChatTab]); const chats = useMemo(() => { if (!threadsData?.threads) return []; @@ -440,26 +433,24 @@ export function LayoutDataProvider({ }, [workspaceToLeave, refetchWorkspaces, workspaceId, router, t]); const handleTabSwitch = useCallback( - (tab: Tab) => { + (tab: ResolvedTab) => { if (tab.type === "chat") { activateChatThread({ - id: tab.chatId ?? null, - title: tab.title, + id: tab.entityId, url: tab.chatUrl, - workspaceId: tab.workspaceId ?? workspaceId, + workspaceId: tab.workspaceId, ...(tab.visibility !== undefined ? { visibility: tab.visibility } : {}), - ...(tab.hasComments !== undefined ? { hasComments: tab.hasComments } : {}), }); } // Document tabs are handled in-place by LayoutShell — no navigation needed }, - [activateChatThread, workspaceId] + [activateChatThread] ); const handleTabPrefetch = useCallback( - (tab: Tab) => { + (tab: ResolvedTab) => { if (tab.type === "chat") { - prefetchChatThread(tab.chatId); + prefetchChatThread(tab.entityId); } }, [prefetchChatThread] @@ -573,16 +564,12 @@ export function LayoutDataProvider({ const fallbackTab = removeChatTab(chatToDelete.id); if (currentChatId === chatToDelete.id) { resetCurrentThread(); - if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) { + if (fallbackTab?.type === "chat") { + const fallbackWorkspaceId = fallbackTab.workspaceId || Number(workspaceId); activateChatThread({ - id: fallbackTab.chatId ?? null, - title: fallbackTab.title, - url: fallbackTab.chatUrl, - workspaceId: fallbackTab.workspaceId ?? workspaceId, - ...(fallbackTab.visibility !== undefined ? { visibility: fallbackTab.visibility } : {}), - ...(fallbackTab.hasComments !== undefined - ? { hasComments: fallbackTab.hasComments } - : {}), + id: fallbackTab.entityId, + url: getChatUrl(fallbackWorkspaceId, fallbackTab.entityId), + workspaceId: fallbackWorkspaceId, }); } else { const isOutOfSync = currentThreadState.id !== null && !params?.chat_id; diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx index e7cdb7d5e..bc861f443 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx @@ -41,6 +41,7 @@ import { useActivateChatThread } from "@/hooks/use-activate-chat-thread"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useLongPress } from "@/hooks/use-long-press"; import { useIsMobile } from "@/hooks/use-mobile"; +import { getChatUrl } from "@/hooks/use-resolved-tabs"; import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations"; import { fetchThreads, searchThreads, type ThreadListItem } from "@/lib/chat/thread-persistence"; import { formatRelativeDate } from "@/lib/format-date"; @@ -145,22 +146,12 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) { if (currentChatId === threadId) { setTimeout(() => { - if ( - fallbackTab?.type === "chat" && - fallbackTab.chatUrl && - fallbackTab.chatId !== undefined - ) { + if (fallbackTab?.type === "chat") { + const fallbackWorkspaceId = fallbackTab.workspaceId || Number(workspaceId); activateChatThread({ - id: fallbackTab.chatId ?? null, - title: fallbackTab.title, - url: fallbackTab.chatUrl, - workspaceId: fallbackTab.workspaceId ?? workspaceId, - ...(fallbackTab.visibility !== undefined - ? { visibility: fallbackTab.visibility } - : {}), - ...(fallbackTab.hasComments !== undefined - ? { hasComments: fallbackTab.hasComments } - : {}), + id: fallbackTab.entityId, + url: getChatUrl(fallbackWorkspaceId, fallbackTab.entityId), + workspaceId: fallbackWorkspaceId, }); return; } From 9f1f727d8906adbac3949671a07b0bd6d81ba171 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:11:29 +0530 Subject: [PATCH 25/34] refactor(tabs): drop snapshot args from tab sync --- .../[workspace_id]/new-chat/[[...chat_id]]/page.tsx | 4 ---- surfsense_web/hooks/use-activate-chat-thread.ts | 6 +----- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx index 22298e574..dff264c43 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx @@ -299,11 +299,7 @@ export default function NewChatPage() { setCurrentThread(thread); syncChatTab({ chatId: thread.id, - title: thread.title, - chatUrl: `/dashboard/${thread.workspace_id ?? workspaceId}/new-chat/${thread.id}`, workspaceId: thread.workspace_id ?? workspaceId, - visibility: thread.visibility, - hasComments: thread.has_comments ?? false, }); } }, [activeThreadId, workspaceId, syncChatTab, threadDetailQuery.data]); diff --git a/surfsense_web/hooks/use-activate-chat-thread.ts b/surfsense_web/hooks/use-activate-chat-thread.ts index 2b68ef76f..c0712908f 100644 --- a/surfsense_web/hooks/use-activate-chat-thread.ts +++ b/surfsense_web/hooks/use-activate-chat-thread.ts @@ -45,17 +45,13 @@ export function useActivateChatThread() { ); const activateChatThread = useCallback( - ({ id, title, url, workspaceId, visibility, hasComments }: ActivateChatThreadInput) => { + ({ id, url, workspaceId, visibility, hasComments }: ActivateChatThreadInput) => { const numericWorkspaceId = getWorkspaceId(workspaceId); const chatUrl = url ?? getChatUrl(workspaceId, id); syncChatTab({ chatId: id, - title: id ? title : (title ?? "New Chat"), - chatUrl, workspaceId: numericWorkspaceId, - ...(visibility !== undefined ? { visibility } : {}), - ...(hasComments !== undefined ? { hasComments } : {}), }); setCurrentThreadMetadata({ From 7b4e4792bffa43b455e3e99fe23f4da5d47f209e Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:16:09 +0530 Subject: [PATCH 26/34] fix(chat): update default chat title to "New Chat" in LayoutDataProvider and useResolvedTabs --- .../components/layout/providers/LayoutDataProvider.tsx | 2 +- surfsense_web/hooks/use-resolved-tabs.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 6858794ca..511c095e5 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -285,7 +285,7 @@ export function LayoutDataProvider({ return threadsData.threads.map((thread) => ({ id: thread.id, - name: thread.title || `Chat ${thread.id}`, + name: thread.title || "New Chat", url: `/dashboard/${workspaceId}/new-chat/${thread.id}`, visibility: thread.visibility, isOwnThread: thread.is_own_thread, diff --git a/surfsense_web/hooks/use-resolved-tabs.ts b/surfsense_web/hooks/use-resolved-tabs.ts index 6b321cbd3..1b072857a 100644 --- a/surfsense_web/hooks/use-resolved-tabs.ts +++ b/surfsense_web/hooks/use-resolved-tabs.ts @@ -66,7 +66,7 @@ export function resolveTabPointers({ const row = tab.entityId === null ? undefined : threads.get(tab.entityId); return { ...tab, - title: row?.title || (tab.entityId === null ? "New Chat" : `Chat ${tab.entityId}`), + title: row?.title || "New Chat", chatUrl: getChatUrl(tab.workspaceId, tab.entityId), ...(row?.visibility !== undefined ? { visibility: row.visibility as ChatVisibility } : {}), }; From c415e68bfc853e34b318d0479963fba6727095ef Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:41:29 +0530 Subject: [PATCH 27/34] refactor(chat): remove loading component and enhance footer visibility logic in ChatViewport --- .../[workspace_id]/new-chat/loading.tsx | 62 ------------------- .../components/assistant-ui/chat-viewport.tsx | 14 ++++- .../components/assistant-ui/thread.tsx | 5 +- 3 files changed, 15 insertions(+), 66 deletions(-) delete mode 100644 surfsense_web/app/dashboard/[workspace_id]/new-chat/loading.tsx diff --git a/surfsense_web/app/dashboard/[workspace_id]/new-chat/loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/new-chat/loading.tsx deleted file mode 100644 index 108671662..000000000 --- a/surfsense_web/app/dashboard/[workspace_id]/new-chat/loading.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
-
-
- {/* User message */} -
- -
- - {/* Assistant message */} -
- - - -
- - {/* User message */} -
- -
- - {/* Assistant message */} -
- - - -
- - {/* User message */} -
- -
-
- - {/* Input bar */} -
-
- -
-
-
-
- ); -} diff --git a/surfsense_web/components/assistant-ui/chat-viewport.tsx b/surfsense_web/components/assistant-ui/chat-viewport.tsx index 83308b642..cb0c57442 100644 --- a/surfsense_web/components/assistant-ui/chat-viewport.tsx +++ b/surfsense_web/components/assistant-ui/chat-viewport.tsx @@ -22,9 +22,19 @@ const ChatScrollToBottom: FC = () => ( export interface ChatViewportProps { children: ReactNode; footer?: ReactNode; + /** + * Keep the footer (composer) pinned even when the thread has no messages — + * needed while an existing thread's messages are still loading, so the + * bottom composer stays visible above the loading skeleton. + */ + footerAlwaysVisible?: boolean; } -export const ChatViewport: FC = ({ children, footer }) => ( +export const ChatViewport: FC = ({ + children, + footer, + footerAlwaysVisible = false, +}) => ( = ({ children, footer }) => ( /> {children} {footer ? ( - !thread.isEmpty}> + footerAlwaysVisible || !thread.isEmpty}> = ({ }} > hasActiveThread || !thread.isEmpty}> + <> - + } > !hasActiveThread && thread.isEmpty}> From 3ec25bd9a49b7f83a9f63881830c1a872f0bbe54 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:41:40 +0530 Subject: [PATCH 28/34] refactor(chat): enhance Composer component to include active thread state and update footer logic --- surfsense_web/components/assistant-ui/thread.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index fc8118fe7..d10841c5c 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -173,7 +173,7 @@ const ThreadContent: FC = ({ footer={ <> - + } > @@ -531,10 +531,14 @@ const ChatUnavailableNotice: FC<{ workspaceId: number; canConfigure: boolean }> }; interface ComposerProps { + hasActiveThread?: boolean; isLoadingMessages?: boolean; } -const Composer: FC = ({ isLoadingMessages = false }) => { +const Composer: FC = ({ + hasActiveThread = false, + isLoadingMessages = false, +}) => { const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const setSubmittedMentions = useSetAtom(submittedMentionsAtom); const [showDocumentPopover, setShowDocumentPopover] = useState(false); @@ -1068,7 +1072,7 @@ const Composer: FC = ({ isLoadingMessages = false }) => { />
{!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? ( From 19a9a84b4524b307ef24d36cdf91d7e6014845ad Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:37:52 +0530 Subject: [PATCH 29/34] fix: increase maximum workspaces per user limit from 100 to 400 --- surfsense_backend/app/config/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 2f909c31f..cd6b9208c 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -857,7 +857,7 @@ class Config: REGISTRATION_ENABLED = os.getenv("REGISTRATION_ENABLED", "TRUE").upper() == "TRUE" # Max workspaces a user may own. The frontend reads this through the # workspace limits route; do not duplicate this value client-side. - MAX_WORKSPACES_PER_USER = int(os.getenv("MAX_WORKSPACES_PER_USER", "100")) + MAX_WORKSPACES_PER_USER = int(os.getenv("MAX_WORKSPACES_PER_USER", "400")) # Google OAuth GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID") From 7ecf01f308044995085eba519ef7c7314789979b Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:38:53 +0530 Subject: [PATCH 30/34] refactor: remove onPaste handler from InlineMentionEditor for cleaner code --- .../components/assistant-ui/inline-mention-editor.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index ee2d1c873..f1b8554e7 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -733,15 +733,9 @@ export const InlineMentionEditor = forwardRef ({ placeholder, - onPaste: (e: React.ClipboardEvent) => { - e.preventDefault(); - const text = e.clipboardData.getData("text/plain"); - const tf = editor.tf as { insertText: (value: string) => void }; - tf.insertText(text); - }, onKeyDown: handleKeyDown, }), - [editor, handleKeyDown, placeholder] + [handleKeyDown, placeholder] ); const mentionEditorContextValue = useMemo( From df98144a243c570d310e0573cd8aaf208010b461 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:55:27 +0530 Subject: [PATCH 31/34] refactor: remove downloading functionality from EditorPanelContent for cleaner code --- .../components/editor-panel/editor-panel.tsx | 59 ------------------- 1 file changed, 59 deletions(-) diff --git a/surfsense_web/components/editor-panel/editor-panel.tsx b/surfsense_web/components/editor-panel/editor-panel.tsx index 4f2f598d8..54b964a41 100644 --- a/surfsense_web/components/editor-panel/editor-panel.tsx +++ b/surfsense_web/components/editor-panel/editor-panel.tsx @@ -4,7 +4,6 @@ import { useAtomValue, useSetAtom } from "jotai"; import { Check, Copy, - Download, FileQuestionMark, FileText, Pencil, @@ -163,7 +162,6 @@ export function EditorPanelContent({ const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); - const [downloading, setDownloading] = useState(false); const [isEditing, setIsEditing] = useState(false); const [memoryLimits, setMemoryLimits] = useState(null); @@ -514,62 +512,6 @@ export function EditorPanelContent({ setIsEditing(false); }, [editorDoc?.source_markdown]); - const handleDownloadMarkdown = useCallback(async () => { - if (!workspaceId || !documentId) return; - setDownloading(true); - try { - const response = await authenticatedFetch( - buildBackendUrl( - `/api/v1/workspaces/${workspaceId}/documents/${documentId}/download-markdown` - ), - { method: "GET" } - ); - if (!response.ok) throw new Error("Download failed"); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - const disposition = response.headers.get("content-disposition"); - const match = disposition?.match(/filename="(.+)"/); - a.download = match?.[1] ?? `${editorDoc?.title || "document"}.md`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - toast.success("Download started"); - } catch { - toast.error("Failed to download document"); - } finally { - setDownloading(false); - } - }, [documentId, editorDoc?.title, workspaceId]); - - const largeDocAlert = viewerMode === "monaco" && !isLocalFileMode && editorDoc && ( - - - - - This document is too large for the editor ( - {formatBytes(editorDoc.content_size_bytes ?? 0)}, {docLineCount.toLocaleString()} lines,{" "} - {editorDoc.chunk_count ?? 0} chunks). Showing raw markdown below. - - - - - ); - return ( <> {showDesktopHeader ? ( @@ -807,7 +749,6 @@ export function EditorPanelContent({ ) : viewerMode === "monaco" && !isLocalFileMode ? ( // Large doc — raw markdown in Monaco. Rich renderers are intentionally skipped.
- {largeDocAlert}
Date: Wed, 22 Jul 2026 12:55:36 +0530 Subject: [PATCH 32/34] refactor: remove unused thread metadata migration and clean up chat thread queries --- .../175_publish_thread_metadata_to_zero.py | 23 ---- surfsense_backend/app/zero_publication.py | 6 +- surfsense_web/hooks/use-resolved-tabs.test.ts | 20 ++-- surfsense_web/hooks/use-resolved-tabs.ts | 100 ++++++++++++------ surfsense_web/zero/queries/chat.ts | 10 -- surfsense_web/zero/queries/documents.ts | 3 - surfsense_web/zero/queries/index.ts | 3 +- surfsense_web/zero/schema/chat.ts | 6 +- 8 files changed, 82 insertions(+), 89 deletions(-) delete mode 100644 surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py diff --git a/surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py b/surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py deleted file mode 100644 index d4d298a4a..000000000 --- a/surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py +++ /dev/null @@ -1,23 +0,0 @@ -"""publish thread metadata to zero_publication - -Revision ID: 175 -Revises: 174 -""" - -from collections.abc import Sequence - -from alembic import op -from app.zero_publication import apply_publication - -revision: str = "175" -down_revision: str | None = "174" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - apply_publication(op.get_bind()) - - -def downgrade() -> None: - """No-op. Historical publication shapes are immutable.""" diff --git a/surfsense_backend/app/zero_publication.py b/surfsense_backend/app/zero_publication.py index f6dff6cdf..ea4d1c90c 100644 --- a/surfsense_backend/app/zero_publication.py +++ b/surfsense_backend/app/zero_publication.py @@ -57,12 +57,12 @@ AUTOMATION_COLS = [ "workspace_id", ] +# Only the columns Zero needs as the authz *parent* of chat messages/comments/ +# session-state (``whereExists("thread")`` + space constraint). Tab titles and +# visibility are resolved over REST (react-query), not pushed via Zero. NEW_CHAT_THREAD_COLS = [ "id", "workspace_id", - "title", - "visibility", - "created_by_id", ] # Enough to drive the lifecycle UI by push: status, the reviewable brief, and diff --git a/surfsense_web/hooks/use-resolved-tabs.test.ts b/surfsense_web/hooks/use-resolved-tabs.test.ts index 34a51df86..dfad6a5a6 100644 --- a/surfsense_web/hooks/use-resolved-tabs.test.ts +++ b/surfsense_web/hooks/use-resolved-tabs.test.ts @@ -1,30 +1,24 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { - getMissingCompleteChatIds, - resolveTabPointers, - type ResolvedTab, -} from "./use-resolved-tabs"; +import { getMissingChatIds, resolveTabPointers, type ResolvedTab } from "./use-resolved-tabs"; // Run with: pnpm exec tsx --test hooks/use-resolved-tabs.test.ts -test("does not prune unresolved chat tabs before Zero completes", () => { - const missing = getMissingCompleteChatIds({ +test("does not prune chat tabs that have not resolved as missing", () => { + const missing = getMissingChatIds({ tabs: [{ id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 }], - threadRows: [], - resultType: "unknown", + notFoundIds: new Set(), }); assert.equal(missing.size, 0); }); -test("prunes unresolved chat tabs only after Zero completes", () => { - const missing = getMissingCompleteChatIds({ +test("prunes only chat tabs whose thread resolved as not found", () => { + const missing = getMissingChatIds({ tabs: [ { id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 }, { id: "chat-43", type: "chat", entityId: 43, workspaceId: 7 }, ], - threadRows: [{ id: 43, title: "Present", visibility: "PRIVATE" }], - resultType: "complete", + notFoundIds: new Set([42]), }); assert.deepEqual([...missing], [42]); diff --git a/surfsense_web/hooks/use-resolved-tabs.ts b/surfsense_web/hooks/use-resolved-tabs.ts index 1b072857a..112d82983 100644 --- a/surfsense_web/hooks/use-resolved-tabs.ts +++ b/surfsense_web/hooks/use-resolved-tabs.ts @@ -1,11 +1,19 @@ "use client"; -import { useQuery } from "@rocicorp/zero/react"; +import { useQueries } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; import { useEffect, useMemo } from "react"; import { pruneMissingChatTabsAtom, type Tab, tabsAtom } from "@/atoms/tabs/tabs.atom"; -import type { ChatVisibility } from "@/lib/chat/thread-persistence"; -import { queries } from "@/zero/queries"; +import { documentsApiService } from "@/lib/apis/documents-api.service"; +import { type ChatVisibility, getThreadFull } from "@/lib/chat/thread-persistence"; +import { NotFoundError } from "@/lib/error"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +// Thread/document metadata is read-only for tabs: the DB is the single source +// of truth and react-query is the cache. Titles/visibility stay fresh because +// the rename/visibility/delete mutations patch these same query caches (see +// lib/chat/thread-cache.ts), so a rename reflects in the tab bar immediately. +const METADATA_STALE_TIME_MS = 60 * 1000; interface ThreadRow { id: number; @@ -38,6 +46,13 @@ function rowById(rows: readonly T[] | undefined): Map< return new Map((rows ?? []).map((row) => [row.id, row])); } +// Retry transient failures (network, 5xx) but never a definitive 404 — a +// missing thread/document is authoritative and should settle immediately so +// the tab can be pruned rather than spun on. +function retryUnlessNotFound(failureCount: number, error: Error): boolean { + return !(error instanceof NotFoundError) && failureCount < 2; +} + export function getChatUrl(workspaceId: number, threadId: number | null): string { return threadId ? `/dashboard/${workspaceId}/new-chat/${threadId}` @@ -59,7 +74,9 @@ export function resolveTabPointers({ return tabs.map((tab) => { if (tab.type === "document") { const title = - tab.entityId === null ? "Document" : (documents.get(tab.entityId)?.title ?? `Document ${tab.entityId}`); + tab.entityId === null + ? "Document" + : (documents.get(tab.entityId)?.title ?? `Document ${tab.entityId}`); return { ...tab, title }; } @@ -73,23 +90,23 @@ export function resolveTabPointers({ }); } -export function getMissingCompleteChatIds({ +/** + * A chat tab is prunable only once its thread metadata fetch has settled as a + * definitive 404 (thread deleted). Transient network/5xx errors are excluded so + * an outage never wrongly closes open tabs. + */ +export function getMissingChatIds({ tabs, - threadRows, - resultType, + notFoundIds, }: { tabs: Tab[]; - threadRows?: readonly ThreadRow[]; - resultType: string; + notFoundIds: Set; }): Set { - if (resultType !== "complete") return new Set(); - - const threadIds = new Set((threadRows ?? []).map((row) => row.id)); return new Set( tabs .filter( (tab): tab is Tab & { type: "chat"; entityId: number } => - tab.type === "chat" && tab.entityId !== null && !threadIds.has(tab.entityId) + tab.type === "chat" && tab.entityId !== null && notFoundIds.has(tab.entityId) ) .map((tab) => tab.entityId) ); @@ -101,29 +118,48 @@ export function useResolvedTabs(): ResolvedTab[] { const chatIds = useMemo(() => uniqueEntityIds(tabs, "chat"), [tabs]); const documentIds = useMemo(() => uniqueEntityIds(tabs, "document"), [tabs]); - const [threadRows, threadResult] = useQuery( - queries.threads.byIds({ ids: chatIds.length > 0 ? chatIds : [-1] }) + + const threadResults = useQueries({ + queries: chatIds.map((id) => ({ + queryKey: cacheKeys.threads.detail(id), + queryFn: () => getThreadFull(id), + staleTime: METADATA_STALE_TIME_MS, + retry: retryUnlessNotFound, + })), + }); + + const documentResults = useQueries({ + queries: documentIds.map((id) => ({ + queryKey: cacheKeys.documents.document(String(id)), + queryFn: () => documentsApiService.getDocument({ id }), + staleTime: METADATA_STALE_TIME_MS, + retry: retryUnlessNotFound, + })), + }); + + const threadRows: ThreadRow[] = threadResults.flatMap((result, index) => + result.data + ? [{ id: chatIds[index], title: result.data.title, visibility: result.data.visibility }] + : [] ); - const [documentRows] = useQuery( - queries.documents.byIds({ ids: documentIds.length > 0 ? documentIds : [-1] }) + const documentRows: DocumentRow[] = documentResults.flatMap((result, index) => + result.data ? [{ id: documentIds[index], title: result.data.title }] : [] ); - const missingChatIds = useMemo( - () => - getMissingCompleteChatIds({ - tabs, - threadRows, - resultType: threadResult.type, - }), - [tabs, threadRows, threadResult.type] - ); + // Stable primitive key of the threads that settled as 404, so the prune + // effect fires only when that set changes — not on every react-query render. + const notFoundChatIdsKey = threadResults + .flatMap((result, index) => (result.error instanceof NotFoundError ? [chatIds[index]] : [])) + .sort((a, b) => a - b) + .join(","); useEffect(() => { - pruneMissingChatTabs(missingChatIds); - }, [missingChatIds, pruneMissingChatTabs]); + const notFoundIds = new Set( + notFoundChatIdsKey ? notFoundChatIdsKey.split(",").map(Number) : [] + ); + const missing = getMissingChatIds({ tabs, notFoundIds }); + if (missing.size > 0) pruneMissingChatTabs(missing); + }, [notFoundChatIdsKey, tabs, pruneMissingChatTabs]); - return useMemo( - () => resolveTabPointers({ tabs, threadRows, documentRows }), - [tabs, threadRows, documentRows] - ); + return resolveTabPointers({ tabs, threadRows, documentRows }); } diff --git a/surfsense_web/zero/queries/chat.ts b/surfsense_web/zero/queries/chat.ts index 06969a4e9..40e09a6ee 100644 --- a/surfsense_web/zero/queries/chat.ts +++ b/surfsense_web/zero/queries/chat.ts @@ -29,13 +29,3 @@ export const chatSessionQueries = { .one() ), }; - -export const threadQueries = { - byIds: defineQuery(z.object({ ids: z.array(z.number()) }), ({ args: { ids }, ctx }) => - constrainToAllowedSpaces(zql.new_chat_threads, ctx) - .where("id", "IN", ids) - .where(({ or, cmp }) => - or(cmp("createdById", ctx?.userId ?? ""), cmp("visibility", "SEARCH_SPACE")) - ) - ), -}; diff --git a/surfsense_web/zero/queries/documents.ts b/surfsense_web/zero/queries/documents.ts index 6c110aee2..d2f483989 100644 --- a/surfsense_web/zero/queries/documents.ts +++ b/surfsense_web/zero/queries/documents.ts @@ -9,9 +9,6 @@ export const documentQueries = { if (!canReadSpace(ctx, workspaceId)) return denySpace(query).orderBy("createdAt", "desc"); return constrainToAllowedSpaces(query, ctx).orderBy("createdAt", "desc"); }), - byIds: defineQuery(z.object({ ids: z.array(z.number()) }), ({ args: { ids }, ctx }) => - constrainToAllowedSpaces(zql.documents, ctx).where("id", "IN", ids) - ), }; export const connectorQueries = { diff --git a/surfsense_web/zero/queries/index.ts b/surfsense_web/zero/queries/index.ts index 2e51bf9a8..45df8fa98 100644 --- a/surfsense_web/zero/queries/index.ts +++ b/surfsense_web/zero/queries/index.ts @@ -1,6 +1,6 @@ import { defineQueries } from "@rocicorp/zero"; import { automationRunQueries } from "./automations"; -import { chatSessionQueries, commentQueries, messageQueries, threadQueries } from "./chat"; +import { chatSessionQueries, commentQueries, messageQueries } from "./chat"; import { connectorQueries, documentQueries } from "./documents"; import { folderQueries } from "./folders"; import { notificationQueries } from "./inbox"; @@ -15,7 +15,6 @@ export const queries = defineQueries({ messages: messageQueries, comments: commentQueries, chatSession: chatSessionQueries, - threads: threadQueries, user: userQueries, automationRuns: automationRunQueries, podcasts: podcastQueries, diff --git a/surfsense_web/zero/schema/chat.ts b/surfsense_web/zero/schema/chat.ts index 81673feaa..c2790d5bc 100644 --- a/surfsense_web/zero/schema/chat.ts +++ b/surfsense_web/zero/schema/chat.ts @@ -20,13 +20,13 @@ export const newChatMessageTable = table("new_chat_messages") }) .primaryKey("id"); +// Published only as the authz parent of chat messages/comments/session-state +// (whereExists("thread") + space constraint). Title/visibility are resolved +// over REST (react-query) for the tab bar, not synced through Zero. export const newChatThreadTable = table("new_chat_threads") .columns({ id: number(), workspaceId: number().from("workspace_id"), - title: string(), - visibility: string(), - createdById: string().optional().from("created_by_id"), }) .primaryKey("id"); From fd6e54f12274623fe551a9b51bb7b863cbced289 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:58:13 +0530 Subject: [PATCH 33/34] refactor: remove test file for use-resolved-tabs to streamline codebase --- surfsense_web/hooks/use-resolved-tabs.test.ts | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 surfsense_web/hooks/use-resolved-tabs.test.ts diff --git a/surfsense_web/hooks/use-resolved-tabs.test.ts b/surfsense_web/hooks/use-resolved-tabs.test.ts deleted file mode 100644 index dfad6a5a6..000000000 --- a/surfsense_web/hooks/use-resolved-tabs.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { getMissingChatIds, resolveTabPointers, type ResolvedTab } from "./use-resolved-tabs"; - -// Run with: pnpm exec tsx --test hooks/use-resolved-tabs.test.ts -test("does not prune chat tabs that have not resolved as missing", () => { - const missing = getMissingChatIds({ - tabs: [{ id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 }], - notFoundIds: new Set(), - }); - - assert.equal(missing.size, 0); -}); - -test("prunes only chat tabs whose thread resolved as not found", () => { - const missing = getMissingChatIds({ - tabs: [ - { id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 }, - { id: "chat-43", type: "chat", entityId: 43, workspaceId: 7 }, - ], - notFoundIds: new Set([42]), - }); - - assert.deepEqual([...missing], [42]); -}); - -test("merges pointer tabs with synced row titles", () => { - const resolved = resolveTabPointers({ - tabs: [ - { id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 }, - { id: "doc-9", type: "document", entityId: 9, workspaceId: 7 }, - ], - threadRows: [{ id: 42, title: "Live chat title", visibility: "SEARCH_SPACE" }], - documentRows: [{ id: 9, title: "Live document title" }], - }); - - assert.deepEqual( - resolved.map((tab): Pick => ({ - id: tab.id, - title: tab.title, - })), - [ - { id: "chat-42", title: "Live chat title" }, - { id: "doc-9", title: "Live document title" }, - ] - ); -}); From ddfddb2d834d11980894baea6f2372b47b7d618d Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:58:31 +0530 Subject: [PATCH 34/34] refactor: update comments and remove staleTime from useResolvedTabs for clarity and consistency --- surfsense_web/hooks/use-resolved-tabs.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/surfsense_web/hooks/use-resolved-tabs.ts b/surfsense_web/hooks/use-resolved-tabs.ts index 112d82983..99bc90100 100644 --- a/surfsense_web/hooks/use-resolved-tabs.ts +++ b/surfsense_web/hooks/use-resolved-tabs.ts @@ -10,11 +10,10 @@ import { NotFoundError } from "@/lib/error"; import { cacheKeys } from "@/lib/query-client/cache-keys"; // Thread/document metadata is read-only for tabs: the DB is the single source -// of truth and react-query is the cache. Titles/visibility stay fresh because -// the rename/visibility/delete mutations patch these same query caches (see -// lib/chat/thread-cache.ts), so a rename reflects in the tab bar immediately. -const METADATA_STALE_TIME_MS = 60 * 1000; - +// of truth and react-query is the cache (default staleTime from the shared +// QueryClient). Titles/visibility stay fresh because the rename/visibility/ +// delete mutations patch these same query caches (see lib/chat/thread-cache.ts), +// so a rename reflects in the tab bar immediately regardless of staleness. interface ThreadRow { id: number; title: string; @@ -123,7 +122,6 @@ export function useResolvedTabs(): ResolvedTab[] { queries: chatIds.map((id) => ({ queryKey: cacheKeys.threads.detail(id), queryFn: () => getThreadFull(id), - staleTime: METADATA_STALE_TIME_MS, retry: retryUnlessNotFound, })), }); @@ -132,7 +130,6 @@ export function useResolvedTabs(): ResolvedTab[] { queries: documentIds.map((id) => ({ queryKey: cacheKeys.documents.document(String(id)), queryFn: () => documentsApiService.getDocument({ id }), - staleTime: METADATA_STALE_TIME_MS, retry: retryUnlessNotFound, })), });