diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index b2ca9d729..106596403 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -6,7 +6,7 @@ import { useAssistantState, } from "@assistant-ui/react"; import { useAtom, useAtomValue } from "jotai"; -import { CheckIcon, CopyIcon, DownloadIcon, RefreshCwIcon } from "lucide-react"; +import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; import type { FC } from "react"; import { useContext, useEffect, useRef, useState } from "react"; import { @@ -23,8 +23,11 @@ import { import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; +import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; import { CommentTrigger } from "@/components/chat-comments/comment-trigger/comment-trigger"; import { useComments } from "@/hooks/use-comments"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { cn } from "@/lib/utils"; export const MessageError: FC = () => { return ( @@ -93,6 +96,7 @@ function parseMessageId(assistantUiMessageId: string | undefined): number | null export const AssistantMessage: FC = () => { const [messageHeight, setMessageHeight] = useState(undefined); + const [isSheetOpen, setIsSheetOpen] = useState(false); const messageRef = useRef(null); const messageId = useAssistantState(({ message }) => message?.id); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); @@ -102,6 +106,11 @@ export const AssistantMessage: FC = () => { addingCommentToMessageIdAtom ); + // Screen size detection for responsive comment UI + // Mobile: < 768px (bottom sheet), Medium: 768px - 1024px (right sheet), Desktop: >= 1024px (inline panel) + const isMediumScreen = useMediaQuery("(min-width: 768px) and (max-width: 1023px)"); + const isDesktop = useMediaQuery("(min-width: 1024px)"); + const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false); const isMessageStreaming = isThreadRunning && isLastMessage; @@ -111,7 +120,8 @@ export const AssistantMessage: FC = () => { enabled: !!dbMessageId, }); - const hasComments = (commentsData?.total_count ?? 0) > 0; + const commentCount = commentsData?.total_count ?? 0; + const hasComments = commentCount > 0; const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId; const showCommentPanel = hasComments || isAddingComment; @@ -120,6 +130,10 @@ export const AssistantMessage: FC = () => { setAddingCommentToMessageId(isAddingComment ? null : dbMessageId); }; + const handleCommentTriggerClick = () => { + setIsSheetOpen(true); + }; + useEffect(() => { if (!messageRef.current) return; const el = messageRef.current; @@ -130,6 +144,11 @@ export const AssistantMessage: FC = () => { return () => observer.disconnect(); }, []); + const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId; + + // Determine sheet side based on screen size + const sheetSide = isMediumScreen ? "right" : "bottom"; + return ( { > + {/* Desktop comment panel - only on lg screens and above */} {searchSpaceId && commentsEnabled && !isMessageStreaming && (
{
)} + + {/* Mobile & Medium screen comment trigger - shown below lg breakpoint */} + {showCommentTrigger && !isDesktop && ( +
+ +
+ )} + + {/* Comment sheet - bottom for mobile, right for medium screens */} + {showCommentTrigger && !isDesktop && ( + + )}
); }; diff --git a/surfsense_web/components/chat-comments/comment-panel-container/comment-panel-container.tsx b/surfsense_web/components/chat-comments/comment-panel-container/comment-panel-container.tsx index 36c634082..197ac0798 100644 --- a/surfsense_web/components/chat-comments/comment-panel-container/comment-panel-container.tsx +++ b/surfsense_web/components/chat-comments/comment-panel-container/comment-panel-container.tsx @@ -19,6 +19,7 @@ export function CommentPanelContainer({ messageId, isOpen, maxHeight, + variant = "desktop", }: CommentPanelContainerProps) { const { data: commentsData, isLoading: isCommentsLoading } = useComments({ messageId, @@ -80,6 +81,7 @@ export function CommentPanelContainer({ onDeleteComment={handleDeleteComment} isSubmitting={isSubmitting} maxHeight={maxHeight} + variant={variant} /> ); } diff --git a/surfsense_web/components/chat-comments/comment-panel-container/types.ts b/surfsense_web/components/chat-comments/comment-panel-container/types.ts index dcc10cba9..e579f8403 100644 --- a/surfsense_web/components/chat-comments/comment-panel-container/types.ts +++ b/surfsense_web/components/chat-comments/comment-panel-container/types.ts @@ -2,4 +2,6 @@ export interface CommentPanelContainerProps { messageId: number; isOpen: boolean; maxHeight?: number; + /** Variant for responsive styling - desktop shows border/bg, mobile is plain */ + variant?: "desktop" | "mobile"; } diff --git a/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx b/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx index 8b6c8efa9..2ec960614 100644 --- a/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx +++ b/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx @@ -3,6 +3,7 @@ import { MessageSquarePlus } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; import { CommentComposer } from "../comment-composer/comment-composer"; import { CommentThread } from "../comment-thread/comment-thread"; import type { CommentPanelProps } from "./types"; @@ -18,6 +19,7 @@ export function CommentPanel({ onDeleteComment, isSubmitting = false, maxHeight, + variant = "desktop", }: CommentPanelProps) { const [isComposerOpen, setIsComposerOpen] = useState(false); @@ -30,9 +32,14 @@ export function CommentPanel({ setIsComposerOpen(false); }; + const isMobile = variant === "mobile"; + if (isLoading) { return ( -
+
Loading comments... @@ -50,8 +57,11 @@ export function CommentPanel({ return (
{hasThreads && (
@@ -82,7 +92,11 @@ export function CommentPanel({
)} -
+
{isComposerOpen ? ( void; isSubmitting?: boolean; maxHeight?: number; + /** Variant for responsive styling - desktop shows border/bg, mobile is plain */ + variant?: "desktop" | "mobile"; } diff --git a/surfsense_web/components/chat-comments/comment-sheet/comment-sheet.tsx b/surfsense_web/components/chat-comments/comment-sheet/comment-sheet.tsx new file mode 100644 index 000000000..27f7eb8b4 --- /dev/null +++ b/surfsense_web/components/chat-comments/comment-sheet/comment-sheet.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { MessageSquare } from "lucide-react"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; +import { CommentPanelContainer } from "../comment-panel-container/comment-panel-container"; +import type { CommentSheetProps } from "./types"; + +export function CommentSheet({ + messageId, + isOpen, + onOpenChange, + commentCount = 0, + side = "bottom", +}: CommentSheetProps) { + const isBottomSheet = side === "bottom"; + + return ( + + + {/* Drag handle indicator - only for bottom sheet */} + {isBottomSheet && ( +
+
+
+ )} + + + + Comments + {commentCount > 0 && ( + + {commentCount} + + )} + + +
+ +
+ + + ); +} diff --git a/surfsense_web/components/chat-comments/comment-sheet/types.ts b/surfsense_web/components/chat-comments/comment-sheet/types.ts new file mode 100644 index 000000000..443a684f5 --- /dev/null +++ b/surfsense_web/components/chat-comments/comment-sheet/types.ts @@ -0,0 +1,8 @@ +export interface CommentSheetProps { + messageId: number; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + commentCount?: number; + /** Side to open the sheet from - bottom for mobile, right for medium screens */ + side?: "bottom" | "right"; +} diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts index 68d8e8e7b..c33969914 100644 --- a/surfsense_web/lib/electric/client.ts +++ b/surfsense_web/lib/electric/client.ts @@ -48,6 +48,10 @@ let initPromise: Promise | null = null; // Cache for sync handles to prevent duplicate subscriptions (memory optimization) const activeSyncHandles = new Map(); +// Track pending sync operations to prevent race conditions +// If a sync is in progress, subsequent calls will wait for it instead of starting a new one +const pendingSyncs = new Map>(); + // Version for sync state - increment this to force fresh sync when Electric config changes // Set to v2 for user-specific database architecture const SYNC_VERSION = 2; @@ -256,7 +260,16 @@ export async function initElectric(userId: string): Promise { return existingHandle; } - // Build params for the shape request + // Check if there's already a pending sync for this shape (prevent race condition) + const pendingSync = pendingSyncs.get(cacheKey); + if (pendingSync) { + console.log(`[Electric] Waiting for pending sync to complete: ${cacheKey}`); + return pendingSync; + } + + // Create and track the sync promise to prevent race conditions + const syncPromise = (async (): Promise => { + // Build params for the shape request // Electric SQL expects params as URL query parameters const params: Record = { table }; @@ -407,7 +420,55 @@ export async function initElectric(userId: string): Promise { ) => Promise<{ unsubscribe: () => void; isUpToDate: boolean; stream: unknown }>; }; }; - const shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig); + + let shape: { unsubscribe: () => void; isUpToDate: boolean; stream: unknown }; + try { + shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig); + } catch (syncError) { + // Handle "Already syncing" error - pglite-sync might not have fully cleaned up yet + const errorMessage = syncError instanceof Error ? syncError.message : String(syncError); + if (errorMessage.includes("Already syncing")) { + console.warn(`[Electric] Already syncing ${table}, waiting for existing sync to settle...`); + + // Wait a short time for pglite-sync to settle + await new Promise(resolve => setTimeout(resolve, 100)); + + // Check if an active handle now exists (another sync might have completed) + const existingHandle = activeSyncHandles.get(cacheKey); + if (existingHandle) { + console.log(`[Electric] Found existing handle after waiting: ${cacheKey}`); + return existingHandle; + } + + // Retry once after waiting + console.log(`[Electric] Retrying sync for ${table}...`); + try { + shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig); + } catch (retryError) { + const retryMessage = retryError instanceof Error ? retryError.message : String(retryError); + if (retryMessage.includes("Already syncing")) { + // Still syncing - create a placeholder handle that indicates the table is being synced + console.warn(`[Electric] ${table} still syncing, creating placeholder handle`); + const placeholderHandle: SyncHandle = { + unsubscribe: () => { + console.log(`[Electric] Placeholder unsubscribe for: ${cacheKey}`); + activeSyncHandles.delete(cacheKey); + }, + get isUpToDate() { + return false; // We don't know the real state + }, + stream: undefined, + initialSyncPromise: Promise.resolve(), // Already syncing means data should be coming + }; + activeSyncHandles.set(cacheKey, placeholderHandle); + return placeholderHandle; + } + throw retryError; + } + } else { + throw syncError; + } + } if (!shape) { throw new Error("syncShapeToTable returned undefined"); @@ -568,6 +629,18 @@ export async function initElectric(userId: string): Promise { } throw error; } + })(); + + // Track the sync promise to prevent concurrent syncs for the same shape + pendingSyncs.set(cacheKey, syncPromise); + + // Clean up the pending sync when done (whether success or failure) + syncPromise.finally(() => { + pendingSyncs.delete(cacheKey); + console.log(`[Electric] Pending sync removed for: ${cacheKey}`); + }); + + return syncPromise; }, }; @@ -613,8 +686,9 @@ export async function cleanupElectric(): Promise { } } } - // Ensure cache is empty + // Ensure caches are empty activeSyncHandles.clear(); + pendingSyncs.clear(); try { // Close the PGlite database connection