diff --git a/surfsense_backend/alembic/versions/103_add_last_login_to_user.py b/surfsense_backend/alembic/versions/103_add_last_login_to_user.py new file mode 100644 index 000000000..20a061082 --- /dev/null +++ b/surfsense_backend/alembic/versions/103_add_last_login_to_user.py @@ -0,0 +1,39 @@ +"""103_add_last_login_to_user + +Revision ID: 103 +Revises: 102 +Create Date: 2026-03-08 + +Adds last_login timestamp column to the user table so we can track +when each user last authenticated. The column is nullable — existing +rows will have NULL until the user's next login. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "103" +down_revision: str | None = "102" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + conn = op.get_bind() + existing_columns = [col["name"] for col in sa.inspect(conn).get_columns("user")] + + if "last_login" not in existing_columns: + op.add_column( + "user", + sa.Column("last_login", sa.TIMESTAMP(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("user", "last_login") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 510f64cc3..9f0af4fc5 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1720,6 +1720,8 @@ if config.AUTH_TYPE == "GOOGLE": display_name = Column(String, nullable=True) avatar_url = Column(String, nullable=True) + last_login = Column(TIMESTAMP(timezone=True), nullable=True) + # Refresh tokens for this user refresh_tokens = relationship( "RefreshToken", @@ -1820,6 +1822,8 @@ else: display_name = Column(String, nullable=True) avatar_url = Column(String, nullable=True) + last_login = Column(TIMESTAMP(timezone=True), nullable=True) + # Refresh tokens for this user refresh_tokens = relationship( "RefreshToken", diff --git a/surfsense_backend/app/prompts/__init__.py b/surfsense_backend/app/prompts/__init__.py index efa31d612..98909a906 100644 --- a/surfsense_backend/app/prompts/__init__.py +++ b/surfsense_backend/app/prompts/__init__.py @@ -109,12 +109,12 @@ SUMMARY_PROMPT_TEMPLATE = PromptTemplate( # Chat Title Generation Prompt # ============================================================================= -TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following conversation. +TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following user query. - The title MUST be between 1 and 6 words - The title MUST be on a single line -- Capture the main topic or intent of the conversation +- Capture the main topic or intent of the query - Do NOT use quotes, punctuation, or formatting - Do NOT include words like "Chat about" or "Discussion of" - Return ONLY the title, nothing else @@ -124,13 +124,9 @@ TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the follo {user_query} - -{assistant_response} - - Title:""" TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate( - input_variables=["user_query", "assistant_response"], + input_variables=["user_query"], template=TITLE_GENERATION_PROMPT, ) diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py index 7d2cc5c77..38ae31269 100644 --- a/surfsense_backend/app/routes/rbac_routes.py +++ b/surfsense_backend/app/routes/rbac_routes.py @@ -510,6 +510,7 @@ async def list_members( "user_email": member_user.email if member_user else None, "user_display_name": member_user.display_name if member_user else None, "user_avatar_url": member_user.avatar_url if member_user else None, + "user_last_login": member_user.last_login if member_user else None, } response.append(membership_dict) @@ -602,6 +603,7 @@ async def update_member_role( "created_at": db_membership.created_at, "role": db_membership.role, "user_email": member_user.email if member_user else None, + "user_last_login": member_user.last_login if member_user else None, } except HTTPException: diff --git a/surfsense_backend/app/schemas/rbac_schemas.py b/surfsense_backend/app/schemas/rbac_schemas.py index 031eef3d2..8de8426c3 100644 --- a/surfsense_backend/app/schemas/rbac_schemas.py +++ b/surfsense_backend/app/schemas/rbac_schemas.py @@ -77,6 +77,7 @@ class MembershipRead(BaseModel): user_email: str | None = None user_display_name: str | None = None user_avatar_url: str | None = None + user_last_login: datetime | None = None class Config: from_attributes = True diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 8d09ff387..3f0cee145 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -1366,6 +1366,38 @@ async def stream_new_chat( del mentioned_documents, mentioned_surfsense_docs, recent_reports del langchain_messages, final_query + # Check if this is the first assistant response so we can generate + # a title in parallel with the agent stream (better UX than waiting + # until after the full response). + assistant_count_result = await session.execute( + select(func.count(NewChatMessage.id)).filter( + NewChatMessage.thread_id == chat_id, + NewChatMessage.role == "assistant", + ) + ) + is_first_response = (assistant_count_result.scalar() or 0) == 0 + + title_task: asyncio.Task[str | None] | None = None + if is_first_response: + + async def _generate_title() -> str | None: + try: + title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm + title_result = await title_chain.ainvoke( + {"user_query": user_query[:500]} + ) + if title_result and hasattr(title_result, "content"): + raw_title = title_result.content.strip() + if raw_title and len(raw_title) <= 100: + return raw_title.strip("\"'") + except Exception: + pass + return None + + title_task = asyncio.create_task(_generate_title()) + + title_emitted = False + _t_stream_start = time.perf_counter() _first_event_logged = False async for sse in _stream_agent_events( @@ -1390,6 +1422,23 @@ async def stream_new_chat( _first_event_logged = True yield sse + # Inject title update mid-stream as soon as the background task finishes + if title_task is not None and title_task.done() and not title_emitted: + generated_title = title_task.result() + if generated_title: + async with shielded_async_session() as title_session: + title_thread_result = await title_session.execute( + select(NewChatThread).filter(NewChatThread.id == chat_id) + ) + title_thread = title_thread_result.scalars().first() + if title_thread: + title_thread.title = generated_title + await title_session.commit() + yield streaming_service.format_thread_title_update( + chat_id, generated_title + ) + title_emitted = True + _perf_log.info( "[stream_new_chat] Agent stream completed in %.3fs (chat_id=%s)", time.perf_counter() - _t_stream_start, @@ -1398,62 +1447,28 @@ async def stream_new_chat( log_system_snapshot("stream_new_chat_END") if stream_result.is_interrupted: + if title_task is not None and not title_task.done(): + title_task.cancel() yield streaming_service.format_finish_step() yield streaming_service.format_finish() yield streaming_service.format_done() return - accumulated_text = stream_result.accumulated_text - - assistant_count_result = await session.execute( - select(func.count(NewChatMessage.id)).filter( - NewChatMessage.thread_id == chat_id, - NewChatMessage.role == "assistant", - ) - ) - assistant_message_count = assistant_count_result.scalar() or 0 - - # Only generate title on the first response (no prior assistant messages) - if assistant_message_count == 0: - generated_title = None - try: - # Generate title using the same LLM - title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm - # Truncate inputs to avoid context length issues - truncated_query = user_query[:500] - truncated_response = accumulated_text[:1000] - title_result = await title_chain.ainvoke( - { - "user_query": truncated_query, - "assistant_response": truncated_response, - } - ) - - # Extract and clean the title - if title_result and hasattr(title_result, "content"): - raw_title = title_result.content.strip() - # Validate the title (reasonable length) - if raw_title and len(raw_title) <= 100: - # Remove any quotes or extra formatting - generated_title = raw_title.strip("\"'") - except Exception: - generated_title = None - - # Only update if LLM succeeded (keep truncated prompt title as fallback) + # If the title task didn't finish during streaming, await it now + if title_task is not None and not title_emitted: + generated_title = await title_task if generated_title: - # Fetch thread and update title - thread_result = await session.execute( - select(NewChatThread).filter(NewChatThread.id == chat_id) - ) - thread = thread_result.scalars().first() - if thread: - thread.title = generated_title - await session.commit() - - # Notify frontend of the title update - yield streaming_service.format_thread_title_update( - chat_id, generated_title + async with shielded_async_session() as title_session: + title_thread_result = await title_session.execute( + select(NewChatThread).filter(NewChatThread.id == chat_id) ) + title_thread = title_thread_result.scalars().first() + if title_thread: + title_thread.title = generated_title + await title_session.commit() + yield streaming_service.format_thread_title_update( + chat_id, generated_title + ) # Finish the step and message yield streaming_service.format_finish_step() diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index 7ec657781..d24a6faf1 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -1,5 +1,6 @@ import logging import uuid +from datetime import UTC, datetime import httpx from fastapi import Depends, Request, Response @@ -12,6 +13,7 @@ from fastapi_users.authentication import ( ) from fastapi_users.db import SQLAlchemyUserDatabase from pydantic import BaseModel +from sqlalchemy import update from app.config import config from app.db import ( @@ -123,6 +125,23 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): return user + async def on_after_login( + self, + user: User, + request: Request | None = None, + response: Response | None = None, + ) -> None: + try: + async with async_session_maker() as session: + await session.execute( + update(User) + .where(User.id == user.id) + .values(last_login=datetime.now(UTC)) + ) + await session.commit() + except Exception as e: + logger.warning(f"Failed to update last_login for user {user.id}: {e}") + async def on_after_register(self, user: User, request: Request | None = None): """ Called after a user registers. Creates a default search space for the user diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx index f75e1d727..d29db13ae 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx @@ -79,7 +79,7 @@ export function DocumentsFilters({
{/* Search input */} -
+
onToggleType(value, !activeTypes.includes(value))} > {/* Icon */} @@ -137,11 +137,11 @@ export function DocumentsFilters({ )}
{activeTypes.length > 0 && ( -
+
) : sorted.length === 0 ? (
-
-
- + {isSearchMode ? ( +
+ +
+

+ No matching documents +

+

+ Try a different search term or adjust your filters. +

+
-
-

{t("no_documents")}

-

- Get started by uploading your first document. -

+ ) : ( +
+
+ +
+
+

{t("no_documents")}

+

+ Get started by uploading your first document. +

+
+
- -
+ )}
) : (
@@ -567,11 +608,20 @@ export function DocumentsTableShell({ {sorted.map((doc) => { const isMentioned = mentionedDocIds?.has(doc.id) ?? false; const canInteract = isSelectable(doc); - const handleRowClick = () => { + const handleRowToggle = () => { if (canInteract && onToggleChatMention) { onToggleChatMention(doc, isMentioned); } }; + const handleRowClick = (e: React.MouseEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + handleViewMetadata(doc); + return; + } + handleRowToggle(); + }; return ( handleRowClick()} + onCheckedChange={() => handleRowToggle()} disabled={!canInteract} aria-label={isMentioned ? "Remove from chat" : "Add to chat"} className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`} @@ -659,21 +710,32 @@ export function DocumentsTableShell({
) : sorted.length === 0 ? (
-
-
- + {isSearchMode ? ( +
+ +
+

No matching documents

+

+ Try a different search term or adjust your filters. +

+
-
-

{t("no_documents")}

-

- Get started by uploading your first document. -

+ ) : ( +
+
+ +
+
+

{t("no_documents")}

+

+ Get started by uploading your first document. +

+
+
- -
+ )}
) : (
{ const isMentioned = mentionedDocIds?.has(doc.id) ?? false; const canInteract = isSelectable(doc); - const handleCardClick = () => { + const handleCardClick = (e?: React.MouseEvent) => { + if (e && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + handleViewMetadata(doc); + return; + } if (canInteract && onToggleChatMention) { onToggleChatMention(doc, isMentioned); } @@ -769,6 +837,21 @@ export function DocumentsTableShell({ + {/* Document Metadata Viewer (Ctrl+Click) */} + { + if (!open) { + setMetadataDoc(null); + setMetadataJson(null); + setMetadataLoading(false); + } + }} + /> + {/* Delete Confirmation Dialog */} !open && setDeleteDoc(null)}> @@ -839,6 +922,7 @@ export function DocumentsTableShell({ } onClick={() => { if (mobileActionDoc) { + onEditNavigate?.(); router.push(`/dashboard/${searchSpaceId}/editor/${mobileActionDoc.id}`); setMobileActionDoc(null); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index 4503ee34d..6bad6112a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -18,7 +18,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { notesApiService } from "@/lib/apis/notes-api.service"; @@ -83,6 +83,7 @@ export default function EditorPage() { const [error, setError] = useState(null); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); + const [editorTitle, setEditorTitle] = useState("Untitled"); // Store the latest markdown from the editor const markdownRef = useRef(""); @@ -117,20 +118,18 @@ export default function EditorPage() { } }, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]); - // Reset state when documentId changes + // Reset state and fetch document content when documentId changes useEffect(() => { setDocument(null); setError(null); setHasUnsavedChanges(false); setLoading(true); initialLoadDone.current = false; - }, [documentId]); - // Fetch document content - useEffect(() => { async function fetchDocument() { if (isNewNote) { markdownRef.current = ""; + setEditorTitle("Untitled"); setDocument({ document_id: 0, title: "Untitled", @@ -173,6 +172,7 @@ export default function EditorPage() { } markdownRef.current = data.source_markdown; + setEditorTitle(extractTitleFromMarkdown(data.source_markdown)); setDocument(data); setError(null); initialLoadDone.current = true; @@ -193,20 +193,17 @@ export default function EditorPage() { const isNote = isNewNote || document?.document_type === "NOTE"; - // Extract title dynamically from current markdown for notes const displayTitle = useMemo(() => { - if (isNote) { - return extractTitleFromMarkdown(markdownRef.current || document?.source_markdown); - } + if (isNote) return editorTitle; return document?.title || "Untitled"; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isNote, document?.title, document?.source_markdown, hasUnsavedChanges]); + }, [isNote, document?.title, editorTitle]); // Handle markdown changes from the Plate editor const handleMarkdownChange = useCallback((md: string) => { markdownRef.current = md; if (initialLoadDone.current) { setHasUnsavedChanges(true); + setEditorTitle(extractTitleFromMarkdown(md)); } }, []); @@ -493,13 +490,13 @@ export default function EditorPage() { Cancel - Save Leave without saving + Save diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 9adf886a4..2fb2527c1 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -465,10 +465,7 @@ export default function NewChatPage() { let isNewThread = false; if (!currentThreadId) { try { - // Create thread with truncated prompt as initial title - const initialTitle = - userQuery.trim().slice(0, 100) + (userQuery.trim().length > 100 ? "..." : ""); - const newThread = await createThread(searchSpaceId, initialTitle); + const newThread = await createThread(searchSpaceId, "New Chat"); currentThreadId = newThread.id; setThreadId(currentThreadId); // Set currentThread so share button in header appears immediately diff --git a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx index 17db12ab7..8dbc6b919 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx @@ -259,7 +259,7 @@ export default function OnboardPage() { You can add more configurations and customize settings anytime in{" "} - {/* Mobile close button */} - -
- {/* Settings Title */} -
-

{t("title")}

-
-
- - {/* Navigation Items */} - - - - ); -} - -function SettingsContent({ - activeSection, - searchSpaceId, - onMenuClick, -}: { - activeSection: string; - searchSpaceId: number; - onMenuClick: () => void; -}) { - const t = useTranslations("searchSpaceSettings"); - const activeItem = settingsNavItems.find((item) => item.id === activeSection); - const Icon = activeItem?.icon || Settings; - - return ( - -
-
- {/* Section Header */} - - -
- {/* Mobile menu button */} - - - - -
-

- {activeItem ? t(activeItem.labelKey) : ""} -

-
-
-
-
- - {/* Section Content */} - - - {activeSection === "general" && ( - - )} - {activeSection === "models" && } - {activeSection === "roles" && } - {activeSection === "image-models" && ( - - )} - {activeSection === "prompts" && } - {activeSection === "public-links" && ( - - )} - {activeSection === "team-roles" && } - - -
-
-
- ); -} - -const VALID_SECTIONS = new Set(settingsNavItems.map((item) => item.id)); -const DEFAULT_SECTION = "general"; +const DEFAULT_TAB = "general"; export default function SettingsPage() { + const t = useTranslations("searchSpaceSettings"); const router = useRouter(); const params = useParams(); const searchParams = useSearchParams(); const searchSpaceId = Number(params.search_space_id); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const sectionParam = searchParams.get("section"); - const activeSection = - sectionParam && VALID_SECTIONS.has(sectionParam) ? sectionParam : DEFAULT_SECTION; + const tabParam = searchParams.get("tab") ?? ""; + const activeTab = VALID_TABS.includes(tabParam as (typeof VALID_TABS)[number]) + ? tabParam + : DEFAULT_TAB; - const handleSectionChange = useCallback( - (section: string) => { - router.replace(`/dashboard/${searchSpaceId}/settings?section=${section}`, { scroll: false }); + const handleTabChange = useCallback( + (value: string) => { + const p = new URLSearchParams(searchParams.toString()); + p.set("tab", value); + router.replace(`?${p.toString()}`, { scroll: false }); }, - [router, searchSpaceId] + [router, searchParams] ); useEffect(() => { - trackSettingsViewed(searchSpaceId, activeSection); - }, [searchSpaceId, activeSection]); - - const handleBackToApp = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/new-chat`); - }, [router, searchSpaceId]); + trackSettingsViewed(searchSpaceId, activeTab); + }, [searchSpaceId, activeTab]); return ( - -
-
- setIsSidebarOpen(false)} - /> - setIsSidebarOpen(true)} - /> -
+
+
+ + + + + {t("nav_general")} + + + + {t("nav_agent_configs")} + + + + {t("nav_role_assignments")} + + + + {t("nav_image_models")} + + + + {t("nav_team_roles")} + + + + {t("nav_system_instructions")} + + + + {t("nav_public_links")} + + + + + + + + + + + + + + + + + + + + + + + +
- +
); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index f5553a580..831a48ed2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -138,6 +138,7 @@ function getAvatarInitials(member: Membership): string { } const PAGE_SIZE = 5; +const SKELETON_KEYS = Array.from({ length: PAGE_SIZE }, (_, i) => `skeleton-${i}`); export default function TeamManagementPage() { const params = useParams(); @@ -290,11 +291,8 @@ export default function TeamManagementPage() { - {Array.from({ length: PAGE_SIZE }).map((_, i) => ( - + {SKELETON_KEYS.map((id) => ( +
@@ -546,7 +544,7 @@ function MemberRow({ - {formatRelativeDate(member.joined_at)} + {member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"} @@ -564,7 +562,7 @@ function MemberRow({ e.preventDefault()} - className="min-w-[120px]" + className="min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5" > {canManageRoles && roles @@ -607,11 +605,9 @@ function MemberRow({ )} - + - router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`) - } + onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?tab=team-roles`)} > Manage Roles @@ -832,7 +828,7 @@ function CreateInviteDialog({
- + @@ -876,10 +872,10 @@ function AllInvitesDialog({ return ( - diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx new file mode 100644 index 000000000..67a7c32a4 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { Check, Copy, Info } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useTranslations } from "next-intl"; +import { useCallback, useRef, useState } from "react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useApiKey } from "@/hooks/use-api-key"; +import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils"; + +export function ApiKeyContent() { + const t = useTranslations("userSettings"); + const { apiKey, isLoading, copied, copyToClipboard } = useApiKey(); + const [copiedUsage, setCopiedUsage] = useState(false); + const usageCopyTimeoutRef = useRef>(null); + + const copyUsageToClipboard = useCallback(async () => { + const text = `Authorization: Bearer ${apiKey || "YOUR_API_KEY"}`; + const success = await copyToClipboardUtil(text); + if (success) { + setCopiedUsage(true); + if (usageCopyTimeoutRef.current) clearTimeout(usageCopyTimeoutRef.current); + usageCopyTimeoutRef.current = setTimeout(() => setCopiedUsage(false), 2000); + } + }, [apiKey]); + + return ( + + + + + {t("api_key_warning_title")} + + {t("api_key_warning_description")} + + + +
+

{t("your_api_key")}

+ {isLoading ? ( +
+ ) : apiKey ? ( +
+
+

+ {apiKey} +

+
+ + + + + + {copied ? t("copied") : t("copy")} + + +
+ ) : ( +

{t("no_api_key")}

+ )} +
+ +
+

{t("usage_title")}

+

{t("usage_description")}

+
+
+
+								Authorization: Bearer {apiKey || "YOUR_API_KEY"}
+							
+
+ + + + + + {copiedUsage ? t("copied") : t("copy")} + + +
+
+ + + ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx new file mode 100644 index 000000000..49f23e85a --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { AnimatePresence, motion } from "motion/react"; +import Image from "next/image"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; + +function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) { + const [errorUrl, setErrorUrl] = useState(); + const hasError = errorUrl === url; + + if (url && !hasError) { + return ( + Avatar setErrorUrl(url)} + unoptimized + /> + ); + } + + return ( +
+ {fallback} +
+ ); +} + +export function ProfileContent() { + const t = useTranslations("userSettings"); + const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom); + const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom); + + const [displayName, setDisplayName] = useState(""); + + useEffect(() => { + if (user) { + setDisplayName(user.display_name || ""); + } + }, [user]); + + const getInitials = (email: string) => { + const name = email.split("@")[0]; + return name.slice(0, 2).toUpperCase(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + await updateUser({ + display_name: displayName || null, + }); + toast.success(t("profile_saved")); + } catch { + toast.error(t("profile_save_error")); + } + }; + + const hasChanges = displayName !== (user?.display_name || ""); + + return ( + + + {isUserLoading ? ( +
+ +
+ ) : ( +
+
+
+
+ + +
+ +
+ + setDisplayName(e.target.value)} + /> +

{t("profile_display_name_hint")}

+
+ +
+ + +
+
+
+ +
+ +
+
+ )} +
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx new file mode 100644 index 000000000..8cdfdc178 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { User, UserKey } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/animated-tabs"; +import { ApiKeyContent } from "./components/ApiKeyContent"; +import { ProfileContent } from "./components/ProfileContent"; + +const VALID_TABS = ["profile", "api-key"] as const; +const DEFAULT_TAB = "profile"; + +export default function UserSettingsPage() { + const t = useTranslations("userSettings"); + const router = useRouter(); + const searchParams = useSearchParams(); + + const tabParam = searchParams.get("tab") ?? ""; + const activeTab = VALID_TABS.includes(tabParam as (typeof VALID_TABS)[number]) + ? tabParam + : DEFAULT_TAB; + + const handleTabChange = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("tab", value); + router.replace(`?${params.toString()}`, { scroll: false }); + }, + [router, searchParams] + ); + + return ( +
+
+ + + + + {t("profile_nav_label")} + + + + {t("api_key_nav_label")} + + + + + + + + + +
+
+ ); +} diff --git a/surfsense_web/app/dashboard/user/settings/components/ApiKeyContent.tsx b/surfsense_web/app/dashboard/user/settings/components/ApiKeyContent.tsx deleted file mode 100644 index 6bf10a78f..000000000 --- a/surfsense_web/app/dashboard/user/settings/components/ApiKeyContent.tsx +++ /dev/null @@ -1,122 +0,0 @@ -"use client"; - -import { Check, Copy, Key, Menu, Shield } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useTranslations } from "next-intl"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { useApiKey } from "@/hooks/use-api-key"; - -interface ApiKeyContentProps { - onMenuClick: () => void; -} - -export function ApiKeyContent({ onMenuClick }: ApiKeyContentProps) { - const t = useTranslations("userSettings"); - const { apiKey, isLoading, copied, copyToClipboard } = useApiKey(); - - return ( - -
-
- - -
- - - - -
-

- {t("api_key_title")} -

-

{t("api_key_description")}

-
-
-
-
- - - - - - {t("api_key_warning_title")} - {t("api_key_warning_description")} - - -
-

{t("your_api_key")}

- {isLoading ? ( -
- ) : apiKey ? ( -
-
- {apiKey} -
- - - - - - {copied ? t("copied") : t("copy")} - - -
- ) : ( -

{t("no_api_key")}

- )} -
- -
-

{t("usage_title")}

-

{t("usage_description")}

-
-									Authorization: Bearer {apiKey || "YOUR_API_KEY"}
-								
-
- - -
-
- - ); -} diff --git a/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx b/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx deleted file mode 100644 index a1ff4d781..000000000 --- a/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx +++ /dev/null @@ -1,182 +0,0 @@ -"use client"; - -import { useAtomValue } from "jotai"; -import { Menu, User } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Spinner } from "@/components/ui/spinner"; - -interface ProfileContentProps { - onMenuClick: () => void; -} - -function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) { - const [hasError, setHasError] = useState(false); - - useEffect(() => { - setHasError(false); - }, [url]); - - if (url && !hasError) { - return ( - Avatar setHasError(true)} - /> - ); - } - - return ( -
- {fallback} -
- ); -} - -export function ProfileContent({ onMenuClick }: ProfileContentProps) { - const t = useTranslations("userSettings"); - const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom); - const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom); - - const [displayName, setDisplayName] = useState(""); - - useEffect(() => { - if (user) { - setDisplayName(user.display_name || ""); - } - }, [user]); - - const getInitials = (email: string) => { - const name = email.split("@")[0]; - return name.slice(0, 2).toUpperCase(); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - try { - await updateUser({ - display_name: displayName || null, - }); - toast.success(t("profile_saved")); - } catch { - toast.error(t("profile_save_error")); - } - }; - - const hasChanges = displayName !== (user?.display_name || ""); - - return ( - -
-
- - -
- - - - -
-

- {t("profile_title")} -

-

{t("profile_description")}

-
-
-
-
- - - - {isUserLoading ? ( -
- -
- ) : ( -
-
-
-
- - -
- -
- - setDisplayName(e.target.value)} - /> -

- {t("profile_display_name_hint")} -

-
- -
- - -
-
-
- -
- -
-
- )} -
-
-
-
-
- ); -} diff --git a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx deleted file mode 100644 index 3424113a9..000000000 --- a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx +++ /dev/null @@ -1,160 +0,0 @@ -"use client"; - -import type { LucideIcon } from "lucide-react"; -import { ArrowLeft, ChevronRight, X } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useTranslations } from "next-intl"; -import { Button } from "@/components/ui/button"; -import { APP_VERSION } from "@/lib/env-config"; -import { cn } from "@/lib/utils"; - -export interface SettingsNavItem { - id: string; - label: string; - description: string; - icon: LucideIcon; -} - -interface UserSettingsSidebarProps { - activeSection: string; - onSectionChange: (section: string) => void; - onBackToApp: () => void; - isOpen: boolean; - onClose: () => void; - navItems: SettingsNavItem[]; -} - -export function UserSettingsSidebar({ - activeSection, - onSectionChange, - onBackToApp, - isOpen, - onClose, - navItems, -}: UserSettingsSidebarProps) { - const t = useTranslations("userSettings"); - - const handleNavClick = (sectionId: string) => { - onSectionChange(sectionId); - onClose(); - }; - - return ( - <> - - {isOpen && ( - - )} - - - - - ); -} diff --git a/surfsense_web/app/dashboard/user/settings/page.tsx b/surfsense_web/app/dashboard/user/settings/page.tsx deleted file mode 100644 index 8e04ce37a..000000000 --- a/surfsense_web/app/dashboard/user/settings/page.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { Key, User } from "lucide-react"; -import { motion } from "motion/react"; -import { useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useState } from "react"; -import { ApiKeyContent } from "./components/ApiKeyContent"; -import { ProfileContent } from "./components/ProfileContent"; -import { type SettingsNavItem, UserSettingsSidebar } from "./components/UserSettingsSidebar"; - -export default function UserSettingsPage() { - const t = useTranslations("userSettings"); - const router = useRouter(); - const [activeSection, setActiveSection] = useState("profile"); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - - const navItems: SettingsNavItem[] = [ - { - id: "profile", - label: t("profile_nav_label"), - description: t("profile_nav_description"), - icon: User, - }, - { - id: "api-key", - label: t("api_key_nav_label"), - description: t("api_key_nav_description"), - icon: Key, - }, - ]; - - const handleBackToApp = useCallback(() => { - router.back(); - }, [router]); - - return ( - -
-
- setIsSidebarOpen(false)} - navItems={navItems} - /> - {activeSection === "profile" && ( - setIsSidebarOpen(true)} /> - )} - {activeSection === "api-key" && ( - setIsSidebarOpen(true)} /> - )} -
-
-
- ); -} diff --git a/surfsense_web/app/globals.css b/surfsense_web/app/globals.css index 9dffa8a03..1f44ead92 100644 --- a/surfsense_web/app/globals.css +++ b/surfsense_web/app/globals.css @@ -195,6 +195,18 @@ button { background-color: hsl(var(--muted-foreground) / 0.4); } +/* Hide scrollbar on mobile, only visible while scrolling */ +@media (max-width: 767px) { + .scrollbar-thin { + scrollbar-width: none; + } + + .scrollbar-thin::-webkit-scrollbar { + width: 0; + display: none; + } +} + /* Human-in-the-loop approval card animations */ @keyframes pulse-subtle { 0%, diff --git a/surfsense_web/atoms/members/members-query.atoms.ts b/surfsense_web/atoms/members/members-query.atoms.ts index f486dc02b..c08a7a337 100644 --- a/surfsense_web/atoms/members/members-query.atoms.ts +++ b/surfsense_web/atoms/members/members-query.atoms.ts @@ -10,6 +10,7 @@ export const membersAtom = atomWithQuery((get) => { queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""), enabled: !!searchSpaceId, staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration + refetchInterval: 2 * 60 * 1000, // 2 minutes queryFn: async () => { if (!searchSpaceId) { return []; diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 5104efc7b..f234f46eb 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -379,7 +379,7 @@ export const ConnectorIndicator: FC = () => { : "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}

diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index c288aacb3..322e136c0 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -6,7 +6,7 @@ import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; @@ -86,6 +86,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid // State for handling new chat navigation when router is out of sync const [pendingNewChat, setPendingNewChat] = useState(false); + // Key used to force-remount the page component (e.g. after deleting the active chat + // when the router is out of sync due to replaceState) + const [chatResetKey, setChatResetKey] = useState(0); + // Current IDs from URL, with fallback to atom for replaceState updates const currentChatId = params?.chat_id ? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id) @@ -132,8 +136,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount; - // Whether any documents are currently being uploaded/indexed — drives sidebar spinner - const isDocumentsProcessing = useDocumentsProcessing(numericSpaceId); + // Document processing status — drives sidebar status indicator (spinner / check / error) + const documentsProcessingStatus = useDocumentsProcessing(numericSpaceId); // Track seen notification IDs to detect new page_limit_exceeded notifications const seenPageLimitNotifications = useRef>(new Set()); @@ -271,7 +275,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid url: "#documents", icon: SquareLibrary, isActive: isDocumentsSidebarOpen, - showSpinner: isDocumentsProcessing, + statusIndicator: documentsProcessingStatus, }, { title: "Announcements", @@ -287,7 +291,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid totalUnreadCount, isAnnouncementsSidebarOpen, announcementUnreadCount, - isDocumentsProcessing, + documentsProcessingStatus, ] ); @@ -304,12 +308,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid }, []); const handleUserSettings = useCallback(() => { - router.push("/dashboard/user/settings"); - }, [router]); + router.push(`/dashboard/${searchSpaceId}/user-settings?tab=profile`); + }, [router, searchSpaceId]); const handleSearchSpaceSettings = useCallback( (space: SearchSpace) => { - router.push(`/dashboard/${space.id}/settings?section=general`); + router.push(`/dashboard/${space.id}/settings?tab=general`); }, [router] ); @@ -478,7 +482,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid ); const handleSettings = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/settings?section=general`); + router.push(`/dashboard/${searchSpaceId}/settings?tab=general`); }, [router, searchSpaceId]); const handleManageMembers = useCallback(() => { @@ -535,7 +539,14 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid await deleteThread(chatToDelete.id); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); if (currentChatId === chatToDelete.id) { - router.push(`/dashboard/${searchSpaceId}/new-chat`); + resetCurrentThread(); + const isOutOfSync = currentThreadState.id !== null && !params?.chat_id; + if (isOutOfSync) { + window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`); + setChatResetKey((k) => k + 1); + } else { + router.push(`/dashboard/${searchSpaceId}/new-chat`); + } } } catch (error) { console.error("Error deleting thread:", error); @@ -544,7 +555,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid setShowDeleteChatDialog(false); setChatToDelete(null); } - }, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]); + }, [ + chatToDelete, + queryClient, + searchSpaceId, + resetCurrentThread, + currentChatId, + currentThreadState.id, + params?.chat_id, + router, + ]); // Rename handler const confirmRenameChat = useCallback(async () => { @@ -660,7 +680,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid onOpenChange: setIsDocumentsSidebarOpen, }} > - {children} + {children} {/* Delete Chat Dialog */} diff --git a/surfsense_web/components/layout/types/layout.types.ts b/surfsense_web/components/layout/types/layout.types.ts index 063a2d38f..720aaecf1 100644 --- a/surfsense_web/components/layout/types/layout.types.ts +++ b/surfsense_web/components/layout/types/layout.types.ts @@ -1,4 +1,5 @@ import type { LucideIcon } from "lucide-react"; +import type { DocumentsProcessingStatus } from "@/hooks/use-documents-processing"; export interface SearchSpace { id: number; @@ -21,7 +22,7 @@ export interface NavItem { icon: LucideIcon; isActive?: boolean; badge?: string | number; - showSpinner?: boolean; + statusIndicator?: DocumentsProcessingStatus; } export interface ChatItem { diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx index 4063ffa63..166bf6ed0 100644 --- a/surfsense_web/components/layout/ui/header/Header.tsx +++ b/surfsense_web/components/layout/ui/header/Header.tsx @@ -39,7 +39,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) { const handleVisibilityChange = (_visibility: ChatVisibility) => {}; return ( -
+
{/* Left side - Mobile menu trigger + Model selector */}
{mobileMenuTrigger} diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx index 514bf7c60..078cea34e 100644 --- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -20,6 +20,7 @@ import { } from "@/components/ui/dropdown-menu"; import { useLongPress } from "@/hooks/use-long-press"; import { useIsMobile } from "@/hooks/use-mobile"; +import { useTypewriter } from "@/hooks/use-typewriter"; import { cn } from "@/lib/utils"; interface ChatListItemProps { @@ -44,6 +45,7 @@ export function ChatListItem({ const t = useTranslations("sidebar"); const isMobile = useIsMobile(); const [dropdownOpen, setDropdownOpen] = useState(false); + const animatedName = useTypewriter(name); const { handlers: longPressHandlers, wasLongPress } = useLongPress( useCallback(() => setDropdownOpen(true), []) @@ -69,7 +71,7 @@ export function ChatListItem({ )} > - {name} + {animatedName} {/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */} @@ -86,7 +88,7 @@ export function ChatListItem({ {t("more_options")} - + {onRename && ( { diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index f76cdeda2..1a21c4b54 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -190,9 +190,10 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) hasMore={hasMore} loadingMore={loadingMore} onLoadMore={onLoadMore} - isSearchMode={isSearchMode} mentionedDocIds={mentionedDocIds} onToggleChatMention={handleToggleChatMention} + onEditNavigate={() => onOpenChange(false)} + isSearchMode={isSearchMode || activeTypes.length > 0} />
diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index f5909ef85..18dafd5d8 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -166,9 +166,30 @@ export function MobileSidebar({ : undefined } user={user} - onSettings={onSettings} - onManageMembers={onManageMembers} - onUserSettings={onUserSettings} + onSettings={ + onSettings + ? () => { + onOpenChange(false); + onSettings(); + } + : undefined + } + onManageMembers={ + onManageMembers + ? () => { + onOpenChange(false); + onManageMembers(); + } + : undefined + } + onUserSettings={ + onUserSettings + ? () => { + onOpenChange(false); + onUserSettings(); + } + : undefined + } onLogout={onLogout} pageUsage={pageUsage} theme={theme} diff --git a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx index fa35b16f7..0e3decd82 100644 --- a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx @@ -1,5 +1,6 @@ "use client"; +import { CheckCircle2, CircleAlert } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; @@ -11,13 +12,67 @@ interface NavSectionProps { isCollapsed?: boolean; } +function StatusBadge({ status }: { status: NavItem["statusIndicator"] }) { + if (status === "processing") { + return ( + + + + ); + } + if (status === "success") { + return ( + + + + ); + } + if (status === "error") { + return ( + + + + ); + } + return null; +} + +function StatusIcon({ + status, + FallbackIcon, + className, +}: { + status: NavItem["statusIndicator"]; + FallbackIcon: NavItem["icon"]; + className?: string; +}) { + if (status === "processing") { + return ; + } + if (status === "success") { + return ( + + ); + } + if (status === "error") { + return ( + + ); + } + return ; +} + export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) { return (
{items.map((item) => { const Icon = item.icon; + const indicator = item.statusIndicator; - // Add data-joyride for onboarding tour const joyrideAttr = item.title === "Documents" || item.title.toLowerCase().includes("documents") ? { "data-joyride": "documents-sidebar" } @@ -40,10 +95,8 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti {...joyrideAttr} > - {item.showSpinner ? ( - - - + {indicator && indicator !== "idle" ? ( + ) : item.badge ? ( {item.badge} @@ -72,11 +125,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti )} {...joyrideAttr} > - {item.showSpinner ? ( - - ) : ( - - )} + {item.title} {item.badge && ( diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index 25e19a9a7..0c7181ee0 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -1,6 +1,18 @@ "use client"; -import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react"; +import { + Check, + ChevronUp, + ExternalLink, + Info, + Languages, + Laptop, + LogOut, + Moon, + Settings, + Sun, +} from "lucide-react"; +import Image from "next/image"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { @@ -16,8 +28,8 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useLocaleContext } from "@/contexts/LocaleContext"; +import { APP_VERSION } from "@/lib/env-config"; import { cn } from "@/lib/utils"; import type { User } from "../../types/layout.types"; @@ -37,6 +49,11 @@ const THEMES = [ { value: "system" as const, name: "System", icon: Laptop }, ]; +const LEARN_MORE_LINKS = [ + { key: "documentation" as const, href: "https://surfsense.com/docs" }, + { key: "github" as const, href: "https://github.com/MODSetter/SurfSense" }, +]; + interface SidebarUserProfileProps { user: User; onUserSettings?: () => void; @@ -100,11 +117,14 @@ function UserAvatar({ }) { if (avatarUrl) { return ( - User avatar ); } @@ -157,25 +177,20 @@ export function SidebarUserProfile({ return (
- - - - - - - {displayName} - + + + @@ -256,6 +271,29 @@ export function SidebarUserProfile({ + + + + {t("learn_more")} + + + + {LEARN_MORE_LINKS.map((link) => ( + + + {t(link.key)} + + + + ))} + +

+ v{APP_VERSION} +

+
+
+
+ @@ -378,6 +416,29 @@ export function SidebarUserProfile({ + + + + {t("learn_more")} + + + + {LEARN_MORE_LINKS.map((link) => ( + + + {t(link.key)} + + + + ))} + +

+ v{APP_VERSION} +

+
+
+
+ diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index 33a52a974..b0f53d06b 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -148,7 +148,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS )} diff --git a/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx b/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx index b6eea7a28..ec45ff2aa 100644 --- a/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx @@ -1,7 +1,7 @@ "use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; -import { AlertTriangleIcon, CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react"; +import { AlertTriangleIcon, CheckIcon, Loader2Icon, Pen, XIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -495,7 +495,7 @@ function ApprovalCard({ )} {canEdit && ( )} diff --git a/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx b/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx index fa1c2c687..8a68a1176 100644 --- a/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx @@ -1,14 +1,7 @@ "use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; -import { - AlertTriangleIcon, - CheckIcon, - InfoIcon, - Loader2Icon, - PencilIcon, - XIcon, -} from "lucide-react"; +import { AlertTriangleIcon, CheckIcon, InfoIcon, Loader2Icon, Pen, XIcon } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -618,7 +611,7 @@ function ApprovalCard({ )} {canEdit && ( )} diff --git a/surfsense_web/components/tool-ui/notion/create-notion-page.tsx b/surfsense_web/components/tool-ui/notion/create-notion-page.tsx index e148c71ba..46e092649 100644 --- a/surfsense_web/components/tool-ui/notion/create-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/create-notion-page.tsx @@ -1,7 +1,7 @@ "use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; -import { AlertTriangleIcon, CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react"; +import { AlertTriangleIcon, CheckIcon, Loader2Icon, Pen, XIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -373,7 +373,7 @@ function ApprovalCard({ )} {canEdit && ( )} diff --git a/surfsense_web/components/tool-ui/notion/update-notion-page.tsx b/surfsense_web/components/tool-ui/notion/update-notion-page.tsx index de8fdd359..885c4325f 100644 --- a/surfsense_web/components/tool-ui/notion/update-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/update-notion-page.tsx @@ -8,7 +8,7 @@ import { Loader2Icon, MaximizeIcon, MinimizeIcon, - PencilIcon, + Pen, XIcon, } from "lucide-react"; import { useMemo, useState } from "react"; @@ -336,7 +336,7 @@ function ApprovalCard({ )} {canEdit && ( )} diff --git a/surfsense_web/components/ui/alert-dialog.tsx b/surfsense_web/components/ui/alert-dialog.tsx index 97264a646..53fa986e6 100644 --- a/surfsense_web/components/ui/alert-dialog.tsx +++ b/surfsense_web/components/ui/alert-dialog.tsx @@ -45,7 +45,7 @@ function AlertDialogContent({ void; +} + +const TabsContext = createContext(null); + +function useTabsContext() { + const ctx = useContext(TabsContext); + if (!ctx) { + throw new Error("AnimatedTabs compound components must be rendered inside "); + } + return ctx; +} + +/* ─────────────────────────── + Constants (hoisted out of render) + ─────────────────────────── */ + +const SIZE_CLASSES = { + sm: "h-[32px] text-sm", + md: "h-[40px] text-base", + lg: "h-[48px] text-lg", +} as const; + +const VARIANT_CLASSES = { + default: "", + pills: "rounded-full", + underlined: "", +} as const; + +const ACTIVE_INDICATOR_CLASSES = { + default: "h-[4px] bg-primary dark:bg-primary", + pills: "hidden", + underlined: "h-[4px] bg-primary dark:bg-primary", +} as const; + +const HOVER_INDICATOR_CLASSES = { + default: "bg-muted dark:bg-muted rounded-[6px]", + pills: "bg-muted dark:bg-muted rounded-full", + underlined: "bg-muted dark:bg-muted rounded-[6px]", +} as const; + +/* ─────────────────────────── + XScrollable (internal) + ─────────────────────────── */ + +const XScrollable = forwardRef< + HTMLDivElement, + { + className?: string; + children?: ReactNode; + showScrollbar?: boolean; + contentClassName?: string; + } & React.HTMLAttributes +>(({ className, children, showScrollbar = true, contentClassName, ...props }, ref) => { + const scrollRef = useRef(null); + const dragging = useRef(false); + const startX = useRef(0); + const startScrollLeft = useRef(0); + const [scrollPos, setScrollPos] = useState<"start" | "middle" | "end">("start"); + + const updateScrollPos = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const canScroll = el.scrollWidth > el.clientWidth + 1; + if (!canScroll) { + setScrollPos("start"); + return; + } + const atStart = el.scrollLeft <= 2; + const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2; + setScrollPos(atStart ? "start" : atEnd ? "end" : "middle"); + }, []); + + useEffect(() => { + updateScrollPos(); + const el = scrollRef.current; + if (!el) return; + const ro = new ResizeObserver(updateScrollPos); + ro.observe(el); + return () => ro.disconnect(); + }, [updateScrollPos]); + + const onMouseDown = (e: React.MouseEvent) => { + if (!scrollRef.current) return; + dragging.current = true; + startX.current = e.clientX; + startScrollLeft.current = scrollRef.current.scrollLeft; + }; + const endDrag = () => { + dragging.current = false; + }; + const onMouseMove = (e: React.MouseEvent) => { + if (!dragging.current || !scrollRef.current) return; + e.preventDefault(); + const dx = e.clientX - startX.current; + scrollRef.current.scrollLeft = startScrollLeft.current - dx; + }; + + const onWheel = (e: React.WheelEvent) => { + if (!scrollRef.current) return; + const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX; + if (delta !== 0) { + e.preventDefault(); + scrollRef.current.scrollLeft += delta; + } + }; + + const handleScroll = useCallback(() => { + updateScrollPos(); + }, [updateScrollPos]); + + const maskStart = scrollPos === "start" ? "black" : "transparent"; + const maskEnd = scrollPos === "end" ? "black" : "transparent"; + const maskImage = `linear-gradient(to right, ${maskStart}, black 24px, black calc(100% - 24px), ${maskEnd})`; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll container needs mouse events +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll requires onMouseDown */} +
+ {children} +
+
+ ); +}); +XScrollable.displayName = "XScrollable"; + +/* ─────────────────────────── + Tabs (root) + ─────────────────────────── */ + +const Tabs = forwardRef< + HTMLDivElement, + { + defaultValue?: string; + value?: string; + onValueChange?: (value: string) => void; + className?: string; + children?: ReactNode; + } +>(({ defaultValue, value, onValueChange, className, children, ...props }, ref) => { + const [activeValue, setActiveValue] = useState(value || defaultValue || ""); + + useEffect(() => { + if (value !== undefined) { + setActiveValue(value); + } + }, [value]); + + const handleValueChange = useCallback( + (newValue: string) => { + if (value === undefined) { + setActiveValue(newValue); + } + onValueChange?.(newValue); + }, + [onValueChange, value] + ); + + return ( + +
+ {children} +
+
+ ); +}); +Tabs.displayName = "Tabs"; + +/* ─────────────────────────── + TabsList + ─────────────────────────── */ + +type TabsListVariant = "default" | "pills" | "underlined"; +type TabsListSize = "sm" | "md" | "lg"; + +const TabsList = forwardRef< + HTMLDivElement, + { + className?: string; + children?: ReactNode; + showHoverEffect?: boolean; + showActiveIndicator?: boolean; + activeIndicatorPosition?: "top" | "bottom"; + activeIndicatorOffset?: number; + size?: TabsListSize; + variant?: TabsListVariant; + stretch?: boolean; + ariaLabel?: string; + showBottomBorder?: boolean; + bottomBorderClassName?: string; + activeIndicatorClassName?: string; + hoverIndicatorClassName?: string; + } +>( + ( + { + className, + children, + showHoverEffect = true, + showActiveIndicator = true, + activeIndicatorPosition = "bottom", + activeIndicatorOffset = 0, + size = "sm", + variant = "default", + stretch = false, + ariaLabel = "Tabs", + showBottomBorder = false, + bottomBorderClassName, + activeIndicatorClassName, + hoverIndicatorClassName, + ...props + }, + ref + ) => { + const { activeValue, onValueChange } = useTabsContext(); + + const [hoveredIndex, setHoveredIndex] = useState(null); + const [hoverStyle, setHoverStyle] = useState({}); + const [activeStyle, setActiveStyle] = useState({ + left: "0px", + width: "0px", + }); + const tabRefs = useRef<(HTMLDivElement | null)[]>([]); + const scrollContainerRef = useRef(null); + + const activeIndex = React.Children.toArray(children).findIndex( + (child) => + React.isValidElement(child) && + (child as React.ReactElement<{ value: string }>).props.value === activeValue + ); + + useEffect(() => { + if (hoveredIndex !== null && showHoverEffect) { + const hoveredElement = tabRefs.current[hoveredIndex]; + if (hoveredElement) { + const { offsetLeft, offsetWidth } = hoveredElement; + setHoverStyle({ + left: `${offsetLeft}px`, + width: `${offsetWidth}px`, + }); + } + } + }, [hoveredIndex, showHoverEffect]); + + const updateActiveIndicator = useCallback(() => { + if (showActiveIndicator && activeIndex >= 0) { + const activeElement = tabRefs.current[activeIndex]; + if (activeElement) { + const { offsetLeft, offsetWidth } = activeElement; + setActiveStyle({ + left: `${offsetLeft}px`, + width: `${offsetWidth}px`, + }); + } + } + }, [showActiveIndicator, activeIndex]); + + useEffect(() => { + updateActiveIndicator(); + }, [updateActiveIndicator]); + + useEffect(() => { + requestAnimationFrame(updateActiveIndicator); + }, [updateActiveIndicator]); + + const scrollTabToCenter = useCallback((index: number) => { + const tabElement = tabRefs.current[index]; + const scrollContainer = scrollContainerRef.current; + + if (tabElement && scrollContainer) { + const containerWidth = scrollContainer.offsetWidth; + const tabWidth = tabElement.offsetWidth; + const tabLeft = tabElement.offsetLeft; + const scrollTarget = tabLeft - containerWidth / 2 + tabWidth / 2; + scrollContainer.scrollTo({ left: scrollTarget, behavior: "smooth" }); + } + }, []); + + const setTabRef = useCallback((el: HTMLDivElement | null, index: number) => { + tabRefs.current[index] = el; + }, []); + + const handleScrollableRef = useCallback((node: HTMLDivElement | null) => { + if (node) { + const scrollableDiv = node.querySelector('div[class*="overflow-x-auto"]'); + if (scrollableDiv) { + scrollContainerRef.current = scrollableDiv as HTMLDivElement; + } + } + }, []); + + useEffect(() => { + if (activeIndex >= 0) { + const timer = setTimeout(() => { + scrollTabToCenter(activeIndex); + }, 100); + return () => clearTimeout(timer); + } + }, [activeIndex, scrollTabToCenter]); + + return ( +
+ {showBottomBorder && ( +
+ )} + +
+ {showHoverEffect && ( + + ); + } +); +TabsList.displayName = "TabsList"; + +/* ─────────────────────────── + TabsTrigger + ─────────────────────────── */ + +const TabsTrigger = forwardRef< + HTMLDivElement, + { + value: string; + disabled?: boolean; + label?: string; + className?: string; + activeClassName?: string; + inactiveClassName?: string; + disabledClassName?: string; + children?: ReactNode; + } +>( + ( + { + value, + disabled = false, + label, + className, + activeClassName, + inactiveClassName, + disabledClassName, + children, + ...props + }, + ref + ) => { + return ( +
+ {label || children} +
+ ); + } +); +TabsTrigger.displayName = "TabsTrigger"; + +/* ─────────────────────────── + TabsContent + ─────────────────────────── */ + +const TabsContent = forwardRef< + HTMLDivElement, + { + value: string; + className?: string; + children: ReactNode; + } +>(({ value, className, children, ...props }, ref) => { + const { activeValue } = useTabsContext(); + + if (value !== activeValue) return null; + return ( +
+ {children} +
+ ); +}); +TabsContent.displayName = "TabsContent"; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/surfsense_web/components/ui/dialog.tsx b/surfsense_web/components/ui/dialog.tsx index d6a5f255b..f13da44a9 100644 --- a/surfsense_web/components/ui/dialog.tsx +++ b/surfsense_web/components/ui/dialog.tsx @@ -38,7 +38,7 @@ const DialogContent = React.forwardRef< {groups.map(({ group, items }) => ( diff --git a/surfsense_web/components/ui/shortcut-kbd.tsx b/surfsense_web/components/ui/shortcut-kbd.tsx index 2da9f85ff..e65c4b586 100644 --- a/surfsense_web/components/ui/shortcut-kbd.tsx +++ b/surfsense_web/components/ui/shortcut-kbd.tsx @@ -9,11 +9,14 @@ export function ShortcutKbd({ keys, className }: ShortcutKbdProps) { if (keys.length === 0) return null; return ( - + {keys.map((key) => ( 1 ? "px-1" : "w-[18px]" + )} > {key} diff --git a/surfsense_web/components/ui/slash-node.tsx b/surfsense_web/components/ui/slash-node.tsx index 8869d8bf5..d5687efcc 100644 --- a/surfsense_web/components/ui/slash-node.tsx +++ b/surfsense_web/components/ui/slash-node.tsx @@ -1,6 +1,5 @@ "use client"; -import { SlashInputPlugin } from "@platejs/slash-command/react"; import { ChevronRightIcon, Code2Icon, @@ -177,7 +176,7 @@ export function SlashInputElement({ children, ...props }: PlateElementProps) { - + No results found. {slashCommandGroups.map(({ heading, items }) => ( diff --git a/surfsense_web/components/ui/turn-into-toolbar-button.tsx b/surfsense_web/components/ui/turn-into-toolbar-button.tsx index ef5d28324..64ae5e82e 100644 --- a/surfsense_web/components/ui/turn-into-toolbar-button.tsx +++ b/surfsense_web/components/ui/turn-into-toolbar-button.tsx @@ -150,7 +150,7 @@ export function TurnIntoToolbarButton({ { e.preventDefault(); editor.tf.focus(); diff --git a/surfsense_web/contracts/types/members.types.ts b/surfsense_web/contracts/types/members.types.ts index 9e0665c65..e458807af 100644 --- a/surfsense_web/contracts/types/members.types.ts +++ b/surfsense_web/contracts/types/members.types.ts @@ -13,6 +13,7 @@ export const membership = z.object({ user_email: z.string().nullable().optional(), user_display_name: z.string().nullable().optional(), user_avatar_url: z.string().nullable().optional(), + user_last_login: z.string().nullable().optional(), user_is_active: z.boolean().nullable().optional(), }); diff --git a/surfsense_web/hooks/use-documents-processing.ts b/surfsense_web/hooks/use-documents-processing.ts index d1788a1b5..bb9901e64 100644 --- a/surfsense_web/hooks/use-documents-processing.ts +++ b/surfsense_web/hooks/use-documents-processing.ts @@ -3,20 +3,23 @@ import { useEffect, useRef, useState } from "react"; import { useElectricClient } from "@/lib/electric/context"; +export type DocumentsProcessingStatus = "idle" | "processing" | "success" | "error"; + +const SUCCESS_LINGER_MS = 5000; + /** - * Returns whether any documents in the search space are currently being - * uploaded or indexed (status = "pending" | "processing"). - * - * Covers both manual file uploads (2-phase pattern) and all connector indexers, - * since both create documents with status = pending before processing. - * - * The sync shape uses the same columns as useDocuments so Electric can share - * the subscription when both hooks are active simultaneously. + * Returns the processing status of documents in the search space: + * - "processing" — at least one doc is pending/processing (show spinner) + * - "error" — nothing processing, but failed docs exist (show red icon) + * - "success" — just transitioned from processing → all clear (green check, auto-dismisses) + * - "idle" — nothing noteworthy (show normal icon) */ -export function useDocumentsProcessing(searchSpaceId: number | null): boolean { +export function useDocumentsProcessing(searchSpaceId: number | null): DocumentsProcessingStatus { const electricClient = useElectricClient(); - const [isProcessing, setIsProcessing] = useState(false); + const [status, setStatus] = useState("idle"); const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null); + const wasProcessingRef = useRef(false); + const successTimerRef = useRef | null>(null); useEffect(() => { if (!searchSpaceId || !electricClient) return; @@ -76,10 +79,15 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean { if (!db.live?.query) return; - const liveQuery = await db.live.query<{ count: number | string }>( - `SELECT COUNT(*) as count FROM documents - WHERE search_space_id = $1 - AND (status->>'state' = 'pending' OR status->>'state' = 'processing')`, + const liveQuery = await db.live.query<{ + processing_count: number | string; + failed_count: number | string; + }>( + `SELECT + SUM(CASE WHEN status->>'state' IN ('pending', 'processing') THEN 1 ELSE 0 END) AS processing_count, + SUM(CASE WHEN status->>'state' = 'failed' THEN 1 ELSE 0 END) AS failed_count + FROM documents + WHERE search_space_id = $1`, [spaceId] ); @@ -88,10 +96,46 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean { return; } - liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => { - if (!mounted || !result.rows?.[0]) return; - setIsProcessing((Number(result.rows[0].count) || 0) > 0); - }); + liveQuery.subscribe( + (result: { + rows: Array<{ processing_count: number | string; failed_count: number | string }>; + }) => { + if (!mounted || !result.rows?.[0]) return; + + const processingCount = Number(result.rows[0].processing_count) || 0; + const failedCount = Number(result.rows[0].failed_count) || 0; + + if (processingCount > 0) { + wasProcessingRef.current = true; + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + successTimerRef.current = null; + } + setStatus("processing"); + } else if (failedCount > 0) { + wasProcessingRef.current = false; + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + successTimerRef.current = null; + } + setStatus("error"); + } else if (wasProcessingRef.current) { + wasProcessingRef.current = false; + setStatus("success"); + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + } + successTimerRef.current = setTimeout(() => { + if (mounted) { + setStatus("idle"); + successTimerRef.current = null; + } + }, SUCCESS_LINGER_MS); + } else { + setStatus("idle"); + } + } + ); liveQueryRef.current = liveQuery; } catch (err) { @@ -103,6 +147,10 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean { return () => { mounted = false; + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + successTimerRef.current = null; + } if (liveQueryRef.current) { try { liveQueryRef.current.unsubscribe?.(); @@ -114,5 +162,5 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean { }; }, [searchSpaceId, electricClient]); - return isProcessing; + return status; } diff --git a/surfsense_web/hooks/use-github-stars.ts b/surfsense_web/hooks/use-github-stars.ts deleted file mode 100644 index aa2bad1b9..000000000 --- a/surfsense_web/hooks/use-github-stars.ts +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -export const useGithubStars = () => { - const [stars, setStars] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const abortController = new AbortController(); - const getStars = async () => { - try { - setError(null); - - const response = await fetch(`https://api.github.com/repos/MODSetter/SurfSense`, { - signal: abortController.signal, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch stars: ${response.statusText}`); - } - - const data = await response.json(); - - setStars(data?.stargazers_count); - } catch (err) { - // Ignore abort errors (expected on unmount) - if (err instanceof Error && err.name === "AbortError") { - return; - } - if (err instanceof Error) { - console.error("Error fetching stars:", err); - setError(err.message); - } - } finally { - setLoading(false); - } - }; - - getStars(); - - return () => { - abortController.abort("Component unmounted"); - }; - }, []); - - return { - stars, - loading, - error, - compactFormat: Intl.NumberFormat("en-US", { - notation: "compact", - maximumFractionDigits: 1, - minimumFractionDigits: 1, - }).format(stars || 0), - }; -}; diff --git a/surfsense_web/hooks/use-typewriter.ts b/surfsense_web/hooks/use-typewriter.ts new file mode 100644 index 000000000..1e1ce8b83 --- /dev/null +++ b/surfsense_web/hooks/use-typewriter.ts @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Animates text changes with a typewriter reveal effect, but only when + * transitioning away from the `skipFor` placeholder (default "New Chat"). + * All other text values are shown instantly without animation. + */ +export function useTypewriter(text: string, speed = 35, skipFor = "New Chat"): string { + const [displayed, setDisplayed] = useState(text); + const prevTextRef = useRef(text); + const intervalRef = useRef | null>(null); + + useEffect(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + const prevText = prevTextRef.current; + prevTextRef.current = text; + + const shouldAnimate = prevText === skipFor && text !== skipFor && !!text; + + if (!shouldAnimate) { + setDisplayed(text); + return; + } + + let i = 0; + setDisplayed(""); + intervalRef.current = setInterval(() => { + i++; + setDisplayed(text.slice(0, i)); + if (i >= text.length) { + if (intervalRef.current) clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, speed); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [text, speed, skipFor]); + + return displayed; +} diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index ddd0682a0..09e3c6107 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -685,6 +685,9 @@ "system": "System", "logout": "Logout", "loggingOut": "Logging out...", + "learn_more": "Learn more", + "documentation": "Documentation", + "github": "GitHub", "inbox": "Inbox", "search_inbox": "Search inbox", "mark_all_read": "Mark all as read", diff --git a/surfsense_web/messages/es.json b/surfsense_web/messages/es.json index 1f046ed47..6ba856b26 100644 --- a/surfsense_web/messages/es.json +++ b/surfsense_web/messages/es.json @@ -685,6 +685,9 @@ "system": "Sistema", "logout": "Cerrar sesión", "loggingOut": "Cerrando sesión...", + "learn_more": "Más información", + "documentation": "Documentación", + "github": "GitHub", "inbox": "Bandeja de entrada", "search_inbox": "Buscar en bandeja de entrada", "mark_all_read": "Marcar todo como leído", diff --git a/surfsense_web/messages/hi.json b/surfsense_web/messages/hi.json index 215924bdf..e0d00c750 100644 --- a/surfsense_web/messages/hi.json +++ b/surfsense_web/messages/hi.json @@ -685,6 +685,9 @@ "system": "सिस्टम", "logout": "लॉगआउट", "loggingOut": "लॉगआउट हो रहा है...", + "learn_more": "और जानें", + "documentation": "दस्तावेज़ीकरण", + "github": "GitHub", "inbox": "इनबॉक्स", "search_inbox": "इनबॉक्स में खोजें", "mark_all_read": "सभी पढ़ा हुआ चिह्नित करें", diff --git a/surfsense_web/messages/pt.json b/surfsense_web/messages/pt.json index 2d958b602..4b833bd33 100644 --- a/surfsense_web/messages/pt.json +++ b/surfsense_web/messages/pt.json @@ -685,6 +685,9 @@ "system": "Sistema", "logout": "Sair", "loggingOut": "Saindo...", + "learn_more": "Saiba mais", + "documentation": "Documentação", + "github": "GitHub", "inbox": "Caixa de entrada", "search_inbox": "Pesquisar caixa de entrada", "mark_all_read": "Marcar tudo como lido", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 24de6f685..b97f1d595 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -669,6 +669,9 @@ "system": "系统", "logout": "退出登录", "loggingOut": "正在退出...", + "learn_more": "了解更多", + "documentation": "文档", + "github": "GitHub", "inbox": "收件箱", "search_inbox": "搜索收件箱", "mark_all_read": "全部标记为已读", diff --git a/surfsense_web/package.json b/surfsense_web/package.json index 0d5956ed9..0789e304e 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -100,7 +100,7 @@ "katex": "^0.16.28", "lenis": "^1.3.17", "lowlight": "^3.3.0", - "lucide-react": "^0.477.0", + "lucide-react": "^0.577.0", "motion": "^12.23.22", "next": "^16.1.0", "next-intl": "^4.6.1", diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml index 5ac3a10bb..b78cc44c8 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -220,13 +220,13 @@ importers: version: 1.4.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) fumadocs-core: specifier: ^16.3.1 - version: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + version: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) fumadocs-mdx: specifier: ^14.2.1 - version: 14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + version: 14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) fumadocs-ui: specifier: ^16.3.1 - version: 16.6.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1) + version: 16.6.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1) geist: specifier: ^1.4.2 version: 1.7.0(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) @@ -246,8 +246,8 @@ importers: specifier: ^3.3.0 version: 3.3.0 lucide-react: - specifier: ^0.477.0 - version: 0.477.0(react@19.2.4) + specifier: ^0.577.0 + version: 0.577.0(react@19.2.4) motion: specifier: ^12.23.22 version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -5641,13 +5641,13 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@0.477.0: - resolution: {integrity: sha512-yCf7aYxerFZAbd8jHJxjwe1j7jEMPptjnaOqdYeirFnEy85cNR3/L+o0I875CYFYya+eEVzZSbNuRk8BZPDpVw==} + lucide-react@0.570.0: + resolution: {integrity: sha512-qGnQ8bEPJLMseKo7kI6jK6GW6Y2Yl4PpqoWbroNsobZ8+tZR4SUuO4EXK3oWCdZr48SZ7PnaulTkvzkKvG/Iqg==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lucide-react@0.570.0: - resolution: {integrity: sha512-qGnQ8bEPJLMseKo7kI6jK6GW6Y2Yl4PpqoWbroNsobZ8+tZR4SUuO4EXK3oWCdZr48SZ7PnaulTkvzkKvG/Iqg==} + lucide-react@0.577.0: + resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -11768,7 +11768,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6): + fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6): dependencies: '@formatjs/intl-localematcher': 0.8.1 '@orama/orama': 3.1.18 @@ -11799,7 +11799,7 @@ snapshots: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 '@types/react': 19.2.14 - lucide-react: 0.477.0(react@19.2.4) + lucide-react: 0.577.0(react@19.2.4) next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -11807,14 +11807,14 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-mdx@14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)): + fumadocs-mdx@14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.27.3 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) js-yaml: 4.1.1 mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 @@ -11837,7 +11837,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.6.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1): + fumadocs-ui@16.6.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1): dependencies: '@fumadocs/tailwind': 0.0.2(tailwindcss@4.2.1) '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -11851,7 +11851,7 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: 0.7.1 - fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.477.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) lucide-react: 0.570.0(react@19.2.4) motion: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -12519,11 +12519,11 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@0.477.0(react@19.2.4): + lucide-react@0.570.0(react@19.2.4): dependencies: react: 19.2.4 - lucide-react@0.570.0(react@19.2.4): + lucide-react@0.577.0(react@19.2.4): dependencies: react: 19.2.4