From 924d18896ae65f24e062098953b5381414772713 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:55:50 +0530 Subject: [PATCH 01/49] feat: implement connector status management and warnings, ran frontend linting - Added a new hook `useConnectorStatus` to manage connector status information. - Introduced `ConnectorStatusBadge` and `ConnectorWarningBanner` components for displaying status and warnings. - Updated `ConnectorCard` and `ConnectorAccountsListView` to utilize the new status management features, including conditional rendering based on connector status and warnings. - Created a configuration file for connector statuses to streamline status management across the application. --- surfsense_web/components/Logo.tsx | 8 +- .../components/connector-card.tsx | 56 ++++++++- .../components/connector-status-badge.tsx | 62 ++++++++++ .../components/connector-warning-banner.tsx | 56 +++++++++ .../config/connector-status-config.ts | 114 ++++++++++++++++++ .../hooks/use-connector-status.ts | 63 ++++++++++ .../views/connector-accounts-list-view.tsx | 70 +++++++---- 7 files changed, 400 insertions(+), 29 deletions(-) create mode 100644 surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/components/connector-warning-banner.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts create mode 100644 surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-status.ts diff --git a/surfsense_web/components/Logo.tsx b/surfsense_web/components/Logo.tsx index 79799942b..58f8d1c9f 100644 --- a/surfsense_web/components/Logo.tsx +++ b/surfsense_web/components/Logo.tsx @@ -7,7 +7,13 @@ import { cn } from "@/lib/utils"; export const Logo = ({ className }: { className?: string }) => { return ( - logo + logo ); }; diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx index e8fe6da33..43c03e03c 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx @@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { LogActiveTask } from "@/contracts/types/log.types"; import { cn } from "@/lib/utils"; +import { useConnectorStatus } from "../hooks/use-connector-status"; +import { ConnectorStatusBadge } from "./connector-status-badge"; interface ConnectorCardProps { id: string; @@ -104,6 +106,21 @@ export const ConnectorCard: FC = ({ onConnect, onManage, }) => { + // Get connector status + const { + getConnectorStatus, + isConnectorEnabled, + getConnectorWarning, + getConnectorStatusMessage, + shouldShowWarnings, + } = useConnectorStatus(); + + const status = getConnectorStatus(connectorType); + const isEnabled = isConnectorEnabled(connectorType); + const warning = getConnectorWarning(connectorType); + const statusMessage = getConnectorStatusMessage(connectorType); + const showWarnings = shouldShowWarnings(); + // Extract count from active task message during indexing const indexingCount = extractIndexedCount(activeTask?.message); @@ -123,6 +140,11 @@ export const ConnectorCard: FC = ({ ); } + // Show status message if available and connector is not connected + if (!isConnected && statusMessage) { + return {statusMessage}; + } + if (isConnected) { // Show last indexed date for connected connectors if (lastIndexedAt) { @@ -136,12 +158,35 @@ export const ConnectorCard: FC = ({ return Never indexed; } + // Show warning message if available and warnings are enabled + if (warning && showWarnings) { + return {warning}; + } + return description; }; return ( -
-
+
+
{connectorType ? ( getConnectorIcon(connectorType, "size-6") ) : id === "youtube-crawler" ? ( @@ -153,6 +198,9 @@ export const ConnectorCard: FC = ({
{title} + {showWarnings && status.status !== "active" && ( + + )}
{getStatusContent()}
{isConnected && documentCount !== undefined && ( @@ -179,10 +227,12 @@ export const ConnectorCard: FC = ({ !isConnected && "shadow-xs" )} onClick={isConnected ? onManage : onConnect} - disabled={isConnecting} + disabled={isConnecting || !isEnabled} > {isConnecting ? ( + ) : !isEnabled ? ( + "Unavailable" ) : isConnected ? ( "Manage" ) : id === "youtube-crawler" ? ( diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx new file mode 100644 index 000000000..0fc48dfb1 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { AlertTriangle, Ban, Wrench } from "lucide-react"; +import type { FC } from "react"; +import type { ConnectorStatus } from "../config/connector-status-config"; +import { cn } from "@/lib/utils"; + +interface ConnectorStatusBadgeProps { + status: ConnectorStatus; + className?: string; +} + +export const ConnectorStatusBadge: FC = ({ status, className }) => { + if (status === "active") { + return null; + } + + const getBadgeConfig = () => { + switch (status) { + case "warning": + return { + icon: AlertTriangle, + className: "text-yellow-500 dark:text-yellow-400", + title: "Warning", + }; + case "disabled": + return { + icon: Ban, + className: "text-red-500 dark:text-red-400", + title: "Disabled", + }; + case "maintenance": + return { + icon: Wrench, + className: "text-orange-500 dark:text-orange-400", + title: "Maintenance", + }; + case "deprecated": + return { + icon: AlertTriangle, + className: "text-amber-500 dark:text-amber-400", + title: "Deprecated", + }; + default: + return null; + } + }; + + const config = getBadgeConfig(); + if (!config) return null; + + const Icon = config.icon; + + return ( +
+ +
+ ); +}; diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-warning-banner.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-warning-banner.tsx new file mode 100644 index 000000000..d1de3e37e --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-warning-banner.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { AlertTriangle, X } from "lucide-react"; +import type { FC } from "react"; +import { useState } from "react"; +import { cn } from "@/lib/utils"; + +interface ConnectorWarningBannerProps { + warning: string; + statusMessage?: string | null; + onDismiss?: () => void; + className?: string; +} + +export const ConnectorWarningBanner: FC = ({ + warning, + statusMessage, + onDismiss, + className, +}) => { + const [isDismissed, setIsDismissed] = useState(false); + + if (isDismissed) return null; + + const handleDismiss = () => { + setIsDismissed(true); + onDismiss?.(); + }; + + return ( +
+ +
+

{warning}

+ {statusMessage && ( +

{statusMessage}

+ )} +
+ {onDismiss && ( + + )} +
+ ); +}; diff --git a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts new file mode 100644 index 000000000..5f3f1fee7 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts @@ -0,0 +1,114 @@ +/** + * Connector Status Configuration + * + * This configuration allows managing connector statuses in the frontend without backend changes. + * Statuses control warnings, disabling connectors, and displaying status messages. + */ + +import { z } from "zod"; + +// Zod schemas for runtime validation and type safety +export const connectorStatusSchema = z.enum([ + "active", + "warning", + "disabled", + "deprecated", + "maintenance", +]); + +export const connectorStatusConfigSchema = z.object({ + enabled: z.boolean(), + status: connectorStatusSchema, + warning: z.string().nullable().optional(), + statusMessage: z.string().nullable().optional(), + disableReason: z.string().nullable().optional(), +}); + +export const connectorStatusMapSchema = z.record(z.string(), connectorStatusConfigSchema); + +export const connectorStatusConfigFileSchema = z.object({ + connectorStatuses: connectorStatusMapSchema, + globalSettings: z.object({ + showWarnings: z.boolean(), + allowManualOverride: z.boolean(), + }), +}); + +// TypeScript types inferred from Zod schemas +export type ConnectorStatus = z.infer; +export type ConnectorStatusConfig = z.infer; +export type ConnectorStatusMap = z.infer; +export type ConnectorStatusConfigFile = z.infer; + +/** + * Default status configuration for all connectors + * Connectors not listed here default to "active" and enabled + * + * This config is validated at runtime using the Zod schema above + */ +const rawConnectorStatusConfig = { + connectorStatuses: { + // Example: Disabled connector + // "SLACK_CONNECTOR": { + // enabled: false, + // status: "disabled", + // warning: null, + // statusMessage: "Slack connector is currently unavailable due to API changes", + // disableReason: "maintenance", + // }, + // Example: Connector with warning + // "NOTION_CONNECTOR": { + // enabled: true, + // status: "warning", + // warning: "Rate limits may apply", + // statusMessage: "Notion API rate limits are currently active. Some requests may be delayed.", + // disableReason: null, + // }, + // Example: Connector in maintenance + // "TEAMS_CONNECTOR": { + // enabled: false, + // status: "maintenance", + // warning: "Under maintenance", + // statusMessage: "Temporarily unavailable for maintenance", + // disableReason: "maintenance", + // }, + }, + globalSettings: { + showWarnings: true, + allowManualOverride: false, + }, +}; + +// Validate the config at module load time (development only) +// In production, this will throw if config is invalid +export const connectorStatusConfig: ConnectorStatusConfigFile = + connectorStatusConfigFileSchema.parse(rawConnectorStatusConfig); + +/** + * Get default status config for a connector (when not in config file) + * Returns a validated default config + */ +export function getDefaultConnectorStatus(): ConnectorStatusConfig { + return connectorStatusConfigSchema.parse({ + enabled: true, + status: "active", + warning: null, + statusMessage: null, + disableReason: null, + }); +} + +/** + * Validate a connector status config object + * Useful for validating config loaded from external sources + */ +export function validateConnectorStatusConfig(config: unknown): ConnectorStatusConfigFile { + return connectorStatusConfigFileSchema.parse(config); +} + +/** + * Validate a single connector status config + */ +export function validateSingleConnectorStatus(config: unknown): ConnectorStatusConfig { + return connectorStatusConfigSchema.parse(config); +} diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-status.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-status.ts new file mode 100644 index 000000000..bb781c879 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-status.ts @@ -0,0 +1,63 @@ +"use client"; + +import { useMemo } from "react"; +import { + type ConnectorStatusConfig, + connectorStatusConfig, + getDefaultConnectorStatus, +} from "../config/connector-status-config"; + +/** + * Hook to get connector status information + */ +export function useConnectorStatus() { + /** + * Get status configuration for a specific connector type + */ + const getConnectorStatus = (connectorType: string | undefined): ConnectorStatusConfig => { + if (!connectorType) { + return getDefaultConnectorStatus(); + } + + return connectorStatusConfig.connectorStatuses[connectorType] || getDefaultConnectorStatus(); + }; + + /** + * Check if a connector is enabled + */ + const isConnectorEnabled = (connectorType: string | undefined): boolean => { + return getConnectorStatus(connectorType).enabled; + }; + + /** + * Get warning message for a connector (if any) + */ + const getConnectorWarning = (connectorType: string | undefined): string | null => { + return getConnectorStatus(connectorType).warning || null; + }; + + /** + * Get status message for a connector + */ + const getConnectorStatusMessage = (connectorType: string | undefined): string | null => { + return getConnectorStatus(connectorType).statusMessage || null; + }; + + /** + * Check if warnings should be shown globally + */ + const shouldShowWarnings = (): boolean => { + return connectorStatusConfig.globalSettings.showWarnings; + }; + + return useMemo( + () => ({ + getConnectorStatus, + isConnectorEnabled, + getConnectorWarning, + getConnectorStatusMessage, + shouldShowWarnings, + }), + [] + ); +} diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx index e45f24d11..df21c0eb5 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx @@ -9,6 +9,8 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types"; import { cn } from "@/lib/utils"; import { getConnectorDisplayName } from "../tabs/all-connectors-tab"; +import { useConnectorStatus } from "../hooks/use-connector-status"; +import { ConnectorWarningBanner } from "../components/connector-warning-banner"; interface ConnectorAccountsListViewProps { connectorType: string; @@ -65,43 +67,57 @@ export const ConnectorAccountsListView: FC = ({ onAddAccount, isConnecting = false, }) => { + // Get connector status + const { isConnectorEnabled, getConnectorWarning, getConnectorStatusMessage, shouldShowWarnings } = + useConnectorStatus(); + + const isEnabled = isConnectorEnabled(connectorType); + const warning = getConnectorWarning(connectorType); + const statusMessage = getConnectorStatusMessage(connectorType); + const showWarnings = shouldShowWarnings(); + // Filter connectors to only show those of this type const typeConnectors = connectors.filter((c) => c.connector_type === connectorType); return (
{/* Header */} -
-
-
- -
-
- {getConnectorIcon(connectorType, "size-5")} -
-
-

{connectorTitle} Accounts

-

- {typeConnectors.length} connected account{typeConnectors.length !== 1 ? "s" : ""} -

-
+
+ {/* Back button */} + + + {/* Connector header */} +
+
+
+ {getConnectorIcon(connectorType, "size-7")} +
+
+

+ {connectorTitle} +

+

+ {statusMessage || "Manage your connector settings and sync configuration"} +

{/* Add Account Button with dashed border */}
{/* Content */} -
+
+ {/* Warning Banner */} + {warning && showWarnings && ( + + )} {/* Connected Accounts Grid */}
{typeConnectors.map((connector) => { From 961d74165674062b8d9eab7d5e1c311d9d50fbeb Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 9 Jan 2026 15:26:55 +0200 Subject: [PATCH 02/49] feat: add SurfsenseDocsDocument model --- surfsense_backend/app/db.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index d54254f9c..abca893fb 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -428,6 +428,28 @@ class Chunk(BaseModel, TimestampMixin): document = relationship("Document", back_populates="chunks") +class SurfsenseDocsDocument(BaseModel, TimestampMixin): + """ + Surfsense documentation storage. + Indexed at migration time from MDX files. + """ + + __tablename__ = "surfsense_docs_documents" + + source = Column(String, nullable=False, unique=True, index=True) # File path: "connectors/slack.mdx" + title = Column(String, nullable=False) + content = Column(Text, nullable=False) + content_hash = Column(String, nullable=False, index=True) # For detecting changes + embedding = Column(Vector(config.embedding_model_instance.dimension)) + updated_at = Column(TIMESTAMP(timezone=True), nullable=True, index=True) + + chunks = relationship( + "SurfsenseDocsChunk", + back_populates="document", + cascade="all, delete-orphan", + ) + + class Podcast(BaseModel, TimestampMixin): """Podcast model for storing generated podcasts.""" From ba404cc1516dc90176637e1e01396b4dfeaf4856 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 9 Jan 2026 15:28:36 +0200 Subject: [PATCH 03/49] feat: add SurfsenseDocsChunk model with relationship --- surfsense_backend/app/db.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index abca893fb..006d73358 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -450,6 +450,22 @@ class SurfsenseDocsDocument(BaseModel, TimestampMixin): ) +class SurfsenseDocsChunk(BaseModel, TimestampMixin): + """Chunk storage for Surfsense documentation.""" + + __tablename__ = "surfsense_docs_chunks" + + content = Column(Text, nullable=False) + embedding = Column(Vector(config.embedding_model_instance.dimension)) + + document_id = Column( + Integer, + ForeignKey("surfsense_docs_documents.id", ondelete="CASCADE"), + nullable=False, + ) + document = relationship("SurfsenseDocsDocument", back_populates="chunks") + + class Podcast(BaseModel, TimestampMixin): """Podcast model for storing generated podcasts.""" From fff851ae3fe07d6b2ac388296c4705083dc131bf Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 9 Jan 2026 15:42:30 +0200 Subject: [PATCH 04/49] feat: create indexer module with MDX parsing --- .../app/tasks/surfsense_docs_indexer.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 surfsense_backend/app/tasks/surfsense_docs_indexer.py diff --git a/surfsense_backend/app/tasks/surfsense_docs_indexer.py b/surfsense_backend/app/tasks/surfsense_docs_indexer.py new file mode 100644 index 000000000..c5e846635 --- /dev/null +++ b/surfsense_backend/app/tasks/surfsense_docs_indexer.py @@ -0,0 +1,64 @@ +""" +Surfsense documentation indexer. +Indexes MDX documentation files at migration time. +""" + +import hashlib +import logging +import re +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Path to docs relative to project root +DOCS_DIR = Path(__file__).resolve().parent.parent.parent.parent / "surfsense_web" / "content" / "docs" + + +def parse_mdx_frontmatter(content: str) -> tuple[str, str]: + """ + Parse MDX file to extract frontmatter title and content. + + Args: + content: Raw MDX file content + + Returns: + Tuple of (title, content_without_frontmatter) + """ + # Match frontmatter between --- markers + frontmatter_pattern = r"^---\s*\n(.*?)\n---\s*\n" + match = re.match(frontmatter_pattern, content, re.DOTALL) + + if match: + frontmatter = match.group(1) + content_without_frontmatter = content[match.end():] + + # Extract title from frontmatter + title_match = re.search(r"^title:\s*(.+)$", frontmatter, re.MULTILINE) + title = title_match.group(1).strip() if title_match else "Untitled" + + # Remove quotes if present + title = title.strip("\"'") + + return title, content_without_frontmatter.strip() + + return "Untitled", content.strip() + + +def get_all_mdx_files() -> list[Path]: + """ + Get all MDX files from the docs directory. + + Returns: + List of Path objects for each MDX file + """ + if not DOCS_DIR.exists(): + logger.warning(f"Docs directory not found: {DOCS_DIR}") + return [] + + return list(DOCS_DIR.rglob("*.mdx")) + + +def generate_surfsense_docs_content_hash(content: str) -> str: + """Generate SHA-256 hash for Surfsense docs content.""" + return hashlib.sha256(content.encode("utf-8")).hexdigest() + From 2e83ed8dcd7affb5d4e8570fdf48ca12031d4335 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 9 Jan 2026 15:45:17 +0200 Subject: [PATCH 05/49] feat: add chunking and embedding logic to indexer --- .../app/tasks/surfsense_docs_indexer.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/surfsense_backend/app/tasks/surfsense_docs_indexer.py b/surfsense_backend/app/tasks/surfsense_docs_indexer.py index c5e846635..6b4c4c91c 100644 --- a/surfsense_backend/app/tasks/surfsense_docs_indexer.py +++ b/surfsense_backend/app/tasks/surfsense_docs_indexer.py @@ -8,6 +8,9 @@ import logging import re from pathlib import Path +from app.config import config +from app.db import SurfsenseDocsChunk + logger = logging.getLogger(__name__) # Path to docs relative to project root @@ -62,3 +65,22 @@ def generate_surfsense_docs_content_hash(content: str) -> str: """Generate SHA-256 hash for Surfsense docs content.""" return hashlib.sha256(content.encode("utf-8")).hexdigest() + +def create_surfsense_docs_chunks(content: str) -> list[SurfsenseDocsChunk]: + """ + Create chunks from Surfsense documentation content. + + Args: + content: Document content to chunk + + Returns: + List of SurfsenseDocsChunk objects with embeddings + """ + return [ + SurfsenseDocsChunk( + content=chunk.text, + embedding=config.embedding_model_instance.embed(chunk.text), + ) + for chunk in config.chunker_instance.chunk(content) + ] + From 105f4c5c9d8eaaeb7a33636441cb8aa31b41dd43 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 9 Jan 2026 15:58:27 +0200 Subject: [PATCH 06/49] feat: add create/update/skip/delete logic to indexer --- .../app/tasks/surfsense_docs_indexer.py | 109 +++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/tasks/surfsense_docs_indexer.py b/surfsense_backend/app/tasks/surfsense_docs_indexer.py index 6b4c4c91c..51a1c0938 100644 --- a/surfsense_backend/app/tasks/surfsense_docs_indexer.py +++ b/surfsense_backend/app/tasks/surfsense_docs_indexer.py @@ -6,10 +6,14 @@ Indexes MDX documentation files at migration time. import hashlib import logging import re +from datetime import UTC, datetime from pathlib import Path +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + from app.config import config -from app.db import SurfsenseDocsChunk +from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument logger = logging.getLogger(__name__) @@ -84,3 +88,106 @@ def create_surfsense_docs_chunks(content: str) -> list[SurfsenseDocsChunk]: for chunk in config.chunker_instance.chunk(content) ] + +def index_surfsense_docs(session: Session) -> tuple[int, int, int, int]: + """ + Index all Surfsense documentation files. + + Args: + session: SQLAlchemy sync session + + Returns: + Tuple of (created, updated, skipped, deleted) counts + """ + created = 0 + updated = 0 + skipped = 0 + deleted = 0 + + # Get all existing docs from database + existing_docs_result = session.execute( + select(SurfsenseDocsDocument).options(selectinload(SurfsenseDocsDocument.chunks)) + ) + existing_docs = {doc.source: doc for doc in existing_docs_result.scalars().all()} + + # Track which sources we've processed + processed_sources = set() + + # Get all MDX files + mdx_files = get_all_mdx_files() + logger.info(f"Found {len(mdx_files)} MDX files to index") + + for mdx_file in mdx_files: + try: + source = str(mdx_file.relative_to(DOCS_DIR)) + processed_sources.add(source) + + # Read file content + raw_content = mdx_file.read_text(encoding="utf-8") + title, content = parse_mdx_frontmatter(raw_content) + content_hash = generate_surfsense_docs_content_hash(raw_content) + + if source in existing_docs: + existing_doc = existing_docs[source] + + # Check if content changed + if existing_doc.content_hash == content_hash: + logger.debug(f"Skipping unchanged: {source}") + skipped += 1 + continue + + # Content changed - update document + logger.info(f"Updating changed document: {source}") + + # Create new chunks + chunks = create_surfsense_docs_chunks(content) + + # Update document fields + existing_doc.title = title + existing_doc.content = content + existing_doc.content_hash = content_hash + existing_doc.embedding = config.embedding_model_instance.embed(content) + existing_doc.chunks = chunks + existing_doc.updated_at = datetime.now(UTC) + + updated += 1 + else: + # New document - create it + logger.info(f"Creating new document: {source}") + + chunks = create_surfsense_docs_chunks(content) + + document = SurfsenseDocsDocument( + source=source, + title=title, + content=content, + content_hash=content_hash, + embedding=config.embedding_model_instance.embed(content), + chunks=chunks, + updated_at=datetime.now(UTC), + ) + + session.add(document) + created += 1 + + except Exception as e: + logger.error(f"Error processing {mdx_file}: {e}", exc_info=True) + continue + + # Delete documents for removed files + for source, doc in existing_docs.items(): + if source not in processed_sources: + logger.info(f"Deleting removed document: {source}") + session.delete(doc) + deleted += 1 + + # Commit all changes + session.commit() + + logger.info( + f"Indexing complete: {created} created, {updated} updated, " + f"{skipped} skipped, {deleted} deleted" + ) + + return created, updated, skipped, deleted + From f30f39b5e960548c2ff267a30cdc9a9e56d13d0b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 9 Jan 2026 16:39:39 +0200 Subject: [PATCH 07/49] feat: create migration for Surfsense docs tables --- .../versions/60_add_surfsense_docs_tables.py | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 surfsense_backend/alembic/versions/60_add_surfsense_docs_tables.py diff --git a/surfsense_backend/alembic/versions/60_add_surfsense_docs_tables.py b/surfsense_backend/alembic/versions/60_add_surfsense_docs_tables.py new file mode 100644 index 000000000..7e5aa9437 --- /dev/null +++ b/surfsense_backend/alembic/versions/60_add_surfsense_docs_tables.py @@ -0,0 +1,165 @@ +"""Add Surfsense docs tables for global documentation storage + +Revision ID: 60 +Revises: 59 +""" + +from collections.abc import Sequence + +from alembic import op + +from app.config import config + +# revision identifiers, used by Alembic. +revision: str = "60" +down_revision: str | None = "59" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# Get embedding dimension from config +EMBEDDING_DIM = config.embedding_model_instance.dimension + + +def upgrade() -> None: + """Create surfsense_docs_documents and surfsense_docs_chunks tables.""" + + # Create surfsense_docs_documents table + op.execute( + f""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'surfsense_docs_documents' + ) THEN + CREATE TABLE surfsense_docs_documents ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + source VARCHAR NOT NULL UNIQUE, + title VARCHAR NOT NULL, + content TEXT NOT NULL, + content_hash VARCHAR NOT NULL, + embedding vector({EMBEDDING_DIM}), + updated_at TIMESTAMP WITH TIME ZONE + ); + END IF; + END$$; + """ + ) + + # Create indexes for surfsense_docs_documents + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE tablename = 'surfsense_docs_documents' AND indexname = 'ix_surfsense_docs_documents_source' + ) THEN + CREATE INDEX ix_surfsense_docs_documents_source ON surfsense_docs_documents(source); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE tablename = 'surfsense_docs_documents' AND indexname = 'ix_surfsense_docs_documents_content_hash' + ) THEN + CREATE INDEX ix_surfsense_docs_documents_content_hash ON surfsense_docs_documents(content_hash); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE tablename = 'surfsense_docs_documents' AND indexname = 'ix_surfsense_docs_documents_updated_at' + ) THEN + CREATE INDEX ix_surfsense_docs_documents_updated_at ON surfsense_docs_documents(updated_at); + END IF; + END$$; + """ + ) + + # Create surfsense_docs_chunks table + op.execute( + f""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'surfsense_docs_chunks' + ) THEN + CREATE TABLE surfsense_docs_chunks ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + content TEXT NOT NULL, + embedding vector({EMBEDDING_DIM}), + document_id INTEGER NOT NULL REFERENCES surfsense_docs_documents(id) ON DELETE CASCADE + ); + END IF; + END$$; + """ + ) + + # Create indexes for surfsense_docs_chunks + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE tablename = 'surfsense_docs_chunks' AND indexname = 'ix_surfsense_docs_chunks_document_id' + ) THEN + CREATE INDEX ix_surfsense_docs_chunks_document_id ON surfsense_docs_chunks(document_id); + END IF; + END$$; + """ + ) + + # Create vector indexes for similarity search + op.execute( + """ + CREATE INDEX IF NOT EXISTS surfsense_docs_documents_vector_index + ON surfsense_docs_documents USING hnsw (embedding public.vector_cosine_ops); + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS surfsense_docs_chunks_vector_index + ON surfsense_docs_chunks USING hnsw (embedding public.vector_cosine_ops); + """ + ) + + # Create full-text search indexes (same pattern as documents/chunks tables) + op.execute( + """ + CREATE INDEX IF NOT EXISTS surfsense_docs_documents_search_index + ON surfsense_docs_documents USING gin (to_tsvector('english', content)); + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS surfsense_docs_chunks_search_index + ON surfsense_docs_chunks USING gin (to_tsvector('english', content)); + """ + ) + + +def downgrade() -> None: + """Remove surfsense docs tables.""" + # Drop full-text search indexes + op.execute("DROP INDEX IF EXISTS surfsense_docs_chunks_search_index") + op.execute("DROP INDEX IF EXISTS surfsense_docs_documents_search_index") + + # Drop vector indexes + op.execute("DROP INDEX IF EXISTS surfsense_docs_chunks_vector_index") + op.execute("DROP INDEX IF EXISTS surfsense_docs_documents_vector_index") + + # Drop regular indexes + op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_chunks_document_id") + op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_documents_updated_at") + op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_documents_content_hash") + op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_documents_source") + + # Drop tables (chunks first due to FK) + op.execute("DROP TABLE IF EXISTS surfsense_docs_chunks") + op.execute("DROP TABLE IF EXISTS surfsense_docs_documents") + From ec145431f2ad1455585d32e1942d51fbb9fb9022 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 9 Jan 2026 16:49:33 +0200 Subject: [PATCH 08/49] feat: add seeding script for Surfsense docs (run after migrations) --- .../scripts/seed_surfsense_docs.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 surfsense_backend/scripts/seed_surfsense_docs.py diff --git a/surfsense_backend/scripts/seed_surfsense_docs.py b/surfsense_backend/scripts/seed_surfsense_docs.py new file mode 100644 index 000000000..2e9eee649 --- /dev/null +++ b/surfsense_backend/scripts/seed_surfsense_docs.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +""" +Seed Surfsense documentation into the database. +Run this script after migrations to index MDX documentation files. + +Usage: + python scripts/seed_surfsense_docs.py +""" + +import sys +from pathlib import Path + +# Add the parent directory to the path so we can import app modules +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.config import config +from app.tasks.surfsense_docs_indexer import index_surfsense_docs + + +def main(): + """Main entry point for seeding Surfsense docs.""" + print("Starting Surfsense docs seeding...") + + # Create sync engine from database URL + # Convert async URL to sync if needed + database_url = config.DATABASE_URL + if database_url.startswith("postgresql+asyncpg://"): + database_url = database_url.replace("postgresql+asyncpg://", "postgresql://") + + engine = create_engine(database_url) + + with Session(engine) as session: + created, updated, skipped, deleted = index_surfsense_docs(session) + + print(f"\nSurfsense docs seeding complete:") + print(f" Created: {created}") + print(f" Updated: {updated}") + print(f" Skipped: {skipped}") + print(f" Deleted: {deleted}") + + +if __name__ == "__main__": + main() + From 6f672361432ccccb3a9ba40f7ac3a1e31a76ed0a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 9 Jan 2026 16:58:09 +0200 Subject: [PATCH 09/49] feat: add docs seeding function to all-in-one entrypoint --- scripts/docker/entrypoint-allinone.sh | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/docker/entrypoint-allinone.sh b/scripts/docker/entrypoint-allinone.sh index 8248968ab..ab21b2658 100644 --- a/scripts/docker/entrypoint-allinone.sh +++ b/scripts/docker/entrypoint-allinone.sh @@ -145,9 +145,29 @@ run_migrations() { echo "✅ Database migrations complete" } +# ================================================ +# Seed Surfsense documentation +# ================================================ +seed_surfsense_docs() { + echo "📚 Seeding Surfsense documentation..." + + # Start PostgreSQL temporarily for seeding + su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D /data/postgres -l /tmp/postgres_seed.log start" + sleep 5 + + cd /app/backend + python scripts/seed_surfsense_docs.py || echo "⚠️ Docs seeding may have already been done" + + # Stop PostgreSQL + su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D /data/postgres stop" + + echo "✅ Surfsense documentation seeded" +} + # Run migrations on first start or when explicitly requested if [ ! -f /data/.migrations_run ] || [ "${FORCE_MIGRATIONS:-false}" = "true" ]; then run_migrations + seed_surfsense_docs touch /data/.migrations_run fi From 4aa686480e9ce5db150353f0ee466c8ba3cf80f7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 9 Jan 2026 17:04:58 +0200 Subject: [PATCH 10/49] refactor: decouple docs seeding from migrations with separate flags --- scripts/docker/entrypoint-allinone.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/docker/entrypoint-allinone.sh b/scripts/docker/entrypoint-allinone.sh index ab21b2658..0888facf1 100644 --- a/scripts/docker/entrypoint-allinone.sh +++ b/scripts/docker/entrypoint-allinone.sh @@ -167,10 +167,15 @@ seed_surfsense_docs() { # Run migrations on first start or when explicitly requested if [ ! -f /data/.migrations_run ] || [ "${FORCE_MIGRATIONS:-false}" = "true" ]; then run_migrations - seed_surfsense_docs touch /data/.migrations_run fi +# Seed docs on first start or when explicitly requested +if [ ! -f /data/.docs_seeded ] || [ "${FORCE_SEED_DOCS:-false}" = "true" ]; then + seed_surfsense_docs + touch /data/.docs_seeded +fi + # ================================================ # Environment Variables Info # ================================================ From 1be9de9c240415e12825a6d33e75d71144fcc5eb Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 9 Jan 2026 17:52:31 +0200 Subject: [PATCH 11/49] feat: add search_surfsense_docs tool with vector search --- .../new_chat/tools/search_surfsense_docs.py | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py diff --git a/surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py b/surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py new file mode 100644 index 000000000..21f3942ab --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py @@ -0,0 +1,160 @@ +""" +Surfsense documentation search tool. + +This tool allows the agent to search the pre-indexed Surfsense documentation +to help users with questions about how to use the application. + +The documentation is indexed at deployment time from MDX files and stored +in dedicated tables (surfsense_docs_documents, surfsense_docs_chunks). +""" + +import json + +from langchain_core.tools import tool +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import config +from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument + + +def format_surfsense_docs_results(results: list[tuple]) -> str: + """ + Format search results into XML structure for the LLM context. + + Uses the same XML structure as format_documents_for_context from knowledge_base.py + but with 'doc-' prefix on chunk IDs. This allows: + - LLM to use consistent [citation:doc-XXX] format + - Frontend to detect 'doc-' prefix and route to surfsense docs endpoint + + Args: + results: List of (chunk, document) tuples from the database query + + Returns: + Formatted XML string with documentation content and citation-ready chunks + """ + if not results: + return "No relevant Surfsense documentation found for your query." + + # Group chunks by document + grouped: dict[int, dict] = {} + for chunk, doc in results: + if doc.id not in grouped: + grouped[doc.id] = { + "document_id": f"doc-{doc.id}", + "document_type": "SURFSENSE_DOCS", + "title": doc.title, + "url": doc.source, + "metadata": {"source": doc.source}, + "chunks": [], + } + grouped[doc.id]["chunks"].append({ + "chunk_id": f"doc-{chunk.id}", + "content": chunk.content, + }) + + # Render XML matching format_documents_for_context structure + parts: list[str] = [] + for g in grouped.values(): + metadata_json = json.dumps(g["metadata"], ensure_ascii=False) + + parts.append("") + parts.append("") + parts.append(f" {g['document_id']}") + parts.append(f" {g['document_type']}") + parts.append(f" <![CDATA[{g['title']}]]>") + parts.append(f" ") + parts.append(f" ") + parts.append("") + parts.append("") + parts.append("") + + for ch in g["chunks"]: + parts.append(f" ") + + parts.append("") + parts.append("") + parts.append("") + + return "\n".join(parts).strip() + + +async def search_surfsense_docs_async( + query: str, + db_session: AsyncSession, + top_k: int = 5, +) -> str: + """ + Search Surfsense documentation using vector similarity. + + Args: + query: The search query about Surfsense usage + db_session: Database session for executing queries + top_k: Number of results to return + + Returns: + Formatted string with relevant documentation content + """ + # Get embedding for the query + query_embedding = config.embedding_model_instance.embed(query) + + # Vector similarity search on chunks, joining with documents + stmt = ( + select(SurfsenseDocsChunk, SurfsenseDocsDocument) + .join( + SurfsenseDocsDocument, + SurfsenseDocsChunk.document_id == SurfsenseDocsDocument.id, + ) + .order_by(SurfsenseDocsChunk.embedding.op("<=>")(query_embedding)) + .limit(top_k) + ) + + result = await db_session.execute(stmt) + rows = result.all() + + return format_surfsense_docs_results(rows) + + +def create_search_surfsense_docs_tool(db_session: AsyncSession): + """ + Factory function to create the search_surfsense_docs tool. + + Args: + db_session: Database session for executing queries + + Returns: + A configured tool function for searching Surfsense documentation + """ + + @tool + async def search_surfsense_docs(query: str, top_k: int = 5) -> str: + """ + Search Surfsense documentation for help with using the application. + + Use this tool when the user asks questions about: + - How to use Surfsense features + - Installation and setup instructions + - Configuration options and settings + - Troubleshooting common issues + - Available connectors and integrations + - Browser extension usage + - API documentation + + This searches the official Surfsense documentation that was indexed + at deployment time. It does NOT search the user's personal knowledge base. + + Args: + query: The search query about Surfsense usage or features + top_k: Number of documentation chunks to retrieve (default: 5) + + Returns: + Relevant documentation content formatted with chunk IDs for citations + """ + return await search_surfsense_docs_async( + query=query, + db_session=db_session, + top_k=top_k, + ) + + return search_surfsense_docs + From c4d214baa4c90a90dfba3da6627131e32a4e97ec Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 9 Jan 2026 17:58:14 +0200 Subject: [PATCH 12/49] feat: register search_surfsense_docs tool in agent toolkit --- .../app/agents/new_chat/tools/__init__.py | 3 +++ .../app/agents/new_chat/tools/registry.py | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/surfsense_backend/app/agents/new_chat/tools/__init__.py b/surfsense_backend/app/agents/new_chat/tools/__init__.py index b89988327..b531d9b4d 100644 --- a/surfsense_backend/app/agents/new_chat/tools/__init__.py +++ b/surfsense_backend/app/agents/new_chat/tools/__init__.py @@ -6,6 +6,7 @@ To add a new tool, see the documentation in registry.py. Available tools: - search_knowledge_base: Search the user's personal knowledge base +- search_surfsense_docs: Search Surfsense documentation for usage help - generate_podcast: Generate audio podcasts from content - link_preview: Fetch rich previews for URLs - display_image: Display images in chat @@ -31,6 +32,7 @@ from .registry import ( get_tool_by_name, ) from .scrape_webpage import create_scrape_webpage_tool +from .search_surfsense_docs import create_search_surfsense_docs_tool __all__ = [ # Registry @@ -43,6 +45,7 @@ __all__ = [ "create_link_preview_tool", "create_scrape_webpage_tool", "create_search_knowledge_base_tool", + "create_search_surfsense_docs_tool", # Knowledge base utilities "format_documents_for_context", "get_all_tool_names", diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index bc305aecc..c7439bf8f 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -48,6 +48,7 @@ from .knowledge_base import create_search_knowledge_base_tool from .link_preview import create_link_preview_tool from .podcast import create_generate_podcast_tool from .scrape_webpage import create_scrape_webpage_tool +from .search_surfsense_docs import create_search_surfsense_docs_tool # ============================================================================= # Tool Definition @@ -126,6 +127,15 @@ BUILTIN_TOOLS: list[ToolDefinition] = [ requires=[], # firecrawl_api_key is optional ), # Note: write_todos is now provided by TodoListMiddleware from deepagents + # Surfsense documentation search tool + ToolDefinition( + name="search_surfsense_docs", + description="Search Surfsense documentation for help with using the application", + factory=lambda deps: create_search_surfsense_docs_tool( + db_session=deps["db_session"], + ), + requires=["db_session"], + ), # ========================================================================= # ADD YOUR CUSTOM TOOLS BELOW # ========================================================================= From 3539b2a83da6be0997c7c63c74b862228fd41291 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 9 Jan 2026 18:07:14 +0200 Subject: [PATCH 13/49] feat: add surfsense docs citation endpoint --- surfsense_backend/app/routes/__init__.py | 2 + .../app/routes/surfsense_docs_routes.py | 89 +++++++++++++++++++ .../app/schemas/surfsense_docs.py | 27 ++++++ 3 files changed, 118 insertions(+) create mode 100644 surfsense_backend/app/routes/surfsense_docs_routes.py create mode 100644 surfsense_backend/app/schemas/surfsense_docs.py diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index b4e94c732..4b6df350a 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -31,6 +31,7 @@ from .rbac_routes import router as rbac_router from .search_source_connectors_routes import router as search_source_connectors_router from .search_spaces_routes import router as search_spaces_router from .slack_add_connector_route import router as slack_add_connector_router +from .surfsense_docs_routes import router as surfsense_docs_router from .teams_add_connector_route import router as teams_add_connector_router router = APIRouter() @@ -59,3 +60,4 @@ router.include_router(clickup_add_connector_router) router.include_router(new_llm_config_router) # LLM configs with prompt configuration router.include_router(logs_router) router.include_router(circleback_webhook_router) # Circleback meeting webhooks +router.include_router(surfsense_docs_router) # Surfsense documentation for citations diff --git a/surfsense_backend/app/routes/surfsense_docs_routes.py b/surfsense_backend/app/routes/surfsense_docs_routes.py new file mode 100644 index 000000000..a2de65568 --- /dev/null +++ b/surfsense_backend/app/routes/surfsense_docs_routes.py @@ -0,0 +1,89 @@ +""" +Routes for Surfsense documentation. + +These endpoints support the citation system for Surfsense docs, +allowing the frontend to fetch document details when a user clicks +on a [citation:doc-XXX] link. +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db import ( + SurfsenseDocsChunk, + SurfsenseDocsDocument, + User, + get_async_session, +) +from app.schemas.surfsense_docs import ( + SurfsenseDocsChunkRead, + SurfsenseDocsDocumentWithChunksRead, +) +from app.users import current_active_user + +router = APIRouter() + + +@router.get( + "/surfsense-docs/by-chunk/{chunk_id}", + response_model=SurfsenseDocsDocumentWithChunksRead, +) +async def get_surfsense_doc_by_chunk_id( + chunk_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Retrieves a Surfsense documentation document based on a chunk ID. + + This endpoint is used by the frontend to resolve [citation:doc-XXX] links. + """ + try: + # Get the chunk + chunk_result = await session.execute( + select(SurfsenseDocsChunk).filter(SurfsenseDocsChunk.id == chunk_id) + ) + chunk = chunk_result.scalars().first() + + if not chunk: + raise HTTPException( + status_code=404, + detail=f"Surfsense docs chunk with id {chunk_id} not found", + ) + + # Get the associated document with all its chunks + document_result = await session.execute( + select(SurfsenseDocsDocument) + .options(selectinload(SurfsenseDocsDocument.chunks)) + .filter(SurfsenseDocsDocument.id == chunk.document_id) + ) + document = document_result.scalars().first() + + if not document: + raise HTTPException( + status_code=404, + detail="Surfsense docs document not found", + ) + + # Sort chunks by ID + sorted_chunks = sorted(document.chunks, key=lambda x: x.id) + + return SurfsenseDocsDocumentWithChunksRead( + id=document.id, + title=document.title, + source=document.source, + content=document.content, + chunks=[ + SurfsenseDocsChunkRead(id=c.id, content=c.content) + for c in sorted_chunks + ], + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to retrieve Surfsense documentation: {e!s}", + ) from e diff --git a/surfsense_backend/app/schemas/surfsense_docs.py b/surfsense_backend/app/schemas/surfsense_docs.py new file mode 100644 index 000000000..7464df342 --- /dev/null +++ b/surfsense_backend/app/schemas/surfsense_docs.py @@ -0,0 +1,27 @@ +""" +Schemas for Surfsense documentation. +""" + +from pydantic import BaseModel, ConfigDict + + +class SurfsenseDocsChunkRead(BaseModel): + """Schema for a Surfsense docs chunk.""" + + id: int + content: str + + model_config = ConfigDict(from_attributes=True) + + +class SurfsenseDocsDocumentWithChunksRead(BaseModel): + """Schema for a Surfsense docs document with its chunks.""" + + id: int + title: str + source: str + content: str + chunks: list[SurfsenseDocsChunkRead] + + model_config = ConfigDict(from_attributes=True) + From abd3bace53e6280e84c723653b2f123ad17e8729 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 9 Jan 2026 18:21:59 +0200 Subject: [PATCH 14/49] feat: add frontend support for surfsense docs citations --- .../assistant-ui/inline-citation.tsx | 9 ++++--- .../components/assistant-ui/markdown-text.tsx | 26 ++++++++++++------- .../new-chat/source-detail-panel.tsx | 11 ++++++-- .../lib/apis/documents-api.service.ts | 12 +++++++++ 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/surfsense_web/components/assistant-ui/inline-citation.tsx b/surfsense_web/components/assistant-ui/inline-citation.tsx index 065f37e8e..9eab9a3c3 100644 --- a/surfsense_web/components/assistant-ui/inline-citation.tsx +++ b/surfsense_web/components/assistant-ui/inline-citation.tsx @@ -7,13 +7,15 @@ import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel"; interface InlineCitationProps { chunkId: number; citationNumber: number; + isDocsChunk?: boolean; } /** * Inline citation component for the new chat. * Renders a clickable numbered badge that opens the SourceDetailPanel with document chunk details. + * Supports both regular knowledge base chunks and Surfsense documentation chunks. */ -export const InlineCitation: FC = ({ chunkId, citationNumber }) => { +export const InlineCitation: FC = ({ chunkId, citationNumber, isDocsChunk = false }) => { const [isOpen, setIsOpen] = useState(false); return ( @@ -21,10 +23,11 @@ export const InlineCitation: FC = ({ chunkId, citationNumbe open={isOpen} onOpenChange={setIsOpen} chunkId={chunkId} - sourceType="" - title="Source" + sourceType={isDocsChunk ? "SURFSENSE_DOCS" : ""} + title={isDocsChunk ? "Surfsense Documentation" : "Source"} description="" url="" + isDocsChunk={isDocsChunk} > setIsOpen(true)} diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index 41d6143b9..532ae7663 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -15,8 +15,8 @@ import { InlineCitation } from "@/components/assistant-ui/inline-citation"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { cn } from "@/lib/utils"; -// Citation pattern: [citation:CHUNK_ID] -const CITATION_REGEX = /\[citation:(\d+)\]/g; +// Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID] +const CITATION_REGEX = /\[citation:(doc-)?(\d+)\]/g; // Track chunk IDs to citation numbers mapping for consistent numbering // This map is reset when a new message starts rendering @@ -33,16 +33,20 @@ export function resetCitationCounter() { /** * Gets or assigns a citation number for a chunk ID + * Uses string key to differentiate between doc and regular chunks */ -function getCitationNumber(chunkId: number): number { - if (!chunkIdToCitationNumber.has(chunkId)) { - chunkIdToCitationNumber.set(chunkId, nextCitationNumber++); +function getCitationNumber(chunkId: number, isDocsChunk: boolean): number { + const key = isDocsChunk ? `doc-${chunkId}` : String(chunkId); + const existingNumber = chunkIdToCitationNumber.get(key as unknown as number); + if (existingNumber === undefined) { + chunkIdToCitationNumber.set(key as unknown as number, nextCitationNumber++); } - return chunkIdToCitationNumber.get(chunkId)!; + return chunkIdToCitationNumber.get(key as unknown as number)!; } /** * Parses text and replaces [citation:XXX] patterns with InlineCitation components + * Supports both regular chunks [citation:123] and docs chunks [citation:doc-123] */ function parseTextWithCitations(text: string): ReactNode[] { const parts: ReactNode[] = []; @@ -59,14 +63,16 @@ function parseTextWithCitations(text: string): ReactNode[] { parts.push(text.substring(lastIndex, match.index)); } - // Add the citation component - const chunkId = Number.parseInt(match[1], 10); - const citationNumber = getCitationNumber(chunkId); + // Check if this is a docs chunk (has "doc-" prefix) + const isDocsChunk = match[1] === "doc-"; + const chunkId = Number.parseInt(match[2], 10); + const citationNumber = getCitationNumber(chunkId, isDocsChunk); parts.push( ); diff --git a/surfsense_web/components/new-chat/source-detail-panel.tsx b/surfsense_web/components/new-chat/source-detail-panel.tsx index 35249dc50..dc0c3c3f8 100644 --- a/surfsense_web/components/new-chat/source-detail-panel.tsx +++ b/surfsense_web/components/new-chat/source-detail-panel.tsx @@ -34,6 +34,7 @@ interface SourceDetailPanelProps { description?: string; url?: string; children?: ReactNode; + isDocsChunk?: boolean; } const formatDocumentType = (type: string) => { @@ -114,6 +115,7 @@ export function SourceDetailPanel({ description, url, children, + isDocsChunk = false, }: SourceDetailPanelProps) { const scrollAreaRef = useRef(null); const hasScrolledRef = useRef(false); // Use ref to avoid stale closures @@ -132,8 +134,13 @@ export function SourceDetailPanel({ isLoading: isDocumentByChunkFetching, error: documentByChunkFetchingError, } = useQuery({ - queryKey: cacheKeys.documents.byChunk(chunkId.toString()), - queryFn: () => documentsApiService.getDocumentByChunk({ chunk_id: chunkId }), + queryKey: isDocsChunk + ? cacheKeys.documents.byChunk(`doc-${chunkId}`) + : cacheKeys.documents.byChunk(chunkId.toString()), + queryFn: () => + isDocsChunk + ? documentsApiService.getSurfsenseDocByChunk(chunkId) + : documentsApiService.getDocumentByChunk({ chunk_id: chunkId }), enabled: !!chunkId && open, staleTime: 5 * 60 * 1000, }); diff --git a/surfsense_web/lib/apis/documents-api.service.ts b/surfsense_web/lib/apis/documents-api.service.ts index cf7a4b778..372baee4d 100644 --- a/surfsense_web/lib/apis/documents-api.service.ts +++ b/surfsense_web/lib/apis/documents-api.service.ts @@ -209,6 +209,18 @@ class DocumentsApiService { ); }; + /** + * Get Surfsense documentation by chunk ID + * Used for resolving [citation:doc-XXX] citations + */ + getSurfsenseDocByChunk = async (chunkId: number) => { + // Response shape matches getDocumentByChunkResponse structure + return baseApiService.get( + `/api/v1/surfsense-docs/by-chunk/${chunkId}`, + getDocumentByChunkResponse + ); + }; + /** * Update a document */ From 207595bb335682afaf7343cd80b90cb3d4544be7 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:16:00 +0530 Subject: [PATCH 15/49] refactor: enhance connector card and status badge components - Updated `ConnectorCard` to prioritize displaying status messages over indexed dates and warnings. - Modified `ConnectorStatusBadge` to use a span instead of a div for better inline flexibility. - Adjusted styles in `ConnectorAccountsListView` for improved layout and spacing. - Cleaned up example status messages in the configuration file for clarity. --- .../connector-popup/components/connector-card.tsx | 13 +++++++------ .../components/connector-status-badge.tsx | 6 +++--- .../config/connector-status-config.ts | 7 ++----- .../views/connector-accounts-list-view.tsx | 14 +++++++------- 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx index 43c03e03c..b302da2b5 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx @@ -140,13 +140,14 @@ export const ConnectorCard: FC = ({ ); } - // Show status message if available and connector is not connected - if (!isConnected && statusMessage) { + // Priority 1: Show status message if available (for both connected and disconnected connectors) + // This takes precedence over indexed dates and warnings + if (statusMessage) { return {statusMessage}; } if (isConnected) { - // Show last indexed date for connected connectors + // Show last indexed date for connected connectors (only if no status message) if (lastIndexedAt) { return ( @@ -158,7 +159,7 @@ export const ConnectorCard: FC = ({ return Never indexed; } - // Show warning message if available and warnings are enabled + // Show warning message if available and warnings are enabled (only if no status message) if (warning && showWarnings) { return {warning}; } @@ -196,10 +197,10 @@ export const ConnectorCard: FC = ({ )}
-
+
{title} {showWarnings && status.status !== "active" && ( - + )}
{getStatusContent()}
diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx index 0fc48dfb1..15e9cadb8 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx @@ -52,11 +52,11 @@ export const ConnectorStatusBadge: FC = ({ status, cl const Icon = config.icon; return ( -
-
+ ); }; diff --git a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts index 5f3f1fee7..afad6e1c8 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts @@ -48,23 +48,20 @@ export type ConnectorStatusConfigFile = z.infer = ({ return (
{/* Header */} -
+
{/* Back button */} @@ -136,7 +136,7 @@ export const ConnectorAccountsListView: FC = ({
{/* Content */} -
+
{/* Warning Banner */} {warning && showWarnings && ( From b0043b6446abed533bccc879df283138422604b6 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:30:33 +0530 Subject: [PATCH 16/49] refactor: streamline connector status handling and remove warnings - Removed the warning message handling from `useConnectorStatus` and related components to simplify status management. - Updated `ConnectorCard` and `ConnectorAccountsListView` to eliminate warning displays, focusing on status messages instead. - Adjusted the connector status configuration to remove warning properties, enhancing clarity and reducing complexity. --- .../components/connector-card.tsx | 7 --- .../config/connector-status-config.ts | 44 +++++++------------ .../hooks/use-connector-status.ts | 8 ---- .../views/connector-accounts-list-view.tsx | 10 +---- 4 files changed, 18 insertions(+), 51 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx index b302da2b5..a3aee33b0 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx @@ -110,14 +110,12 @@ export const ConnectorCard: FC = ({ const { getConnectorStatus, isConnectorEnabled, - getConnectorWarning, getConnectorStatusMessage, shouldShowWarnings, } = useConnectorStatus(); const status = getConnectorStatus(connectorType); const isEnabled = isConnectorEnabled(connectorType); - const warning = getConnectorWarning(connectorType); const statusMessage = getConnectorStatusMessage(connectorType); const showWarnings = shouldShowWarnings(); @@ -159,11 +157,6 @@ export const ConnectorCard: FC = ({ return Never indexed; } - // Show warning message if available and warnings are enabled (only if no status message) - if (warning && showWarnings) { - return {warning}; - } - return description; }; diff --git a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts index afad6e1c8..e6586e110 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts @@ -1,8 +1,8 @@ /** * Connector Status Configuration * - * This configuration allows managing connector statuses in the frontend without backend changes. - * Statuses control warnings, disabling connectors, and displaying status messages. + * This configuration allows managing connector statuses. + * Statuses control disabling connectors and displaying status messages. */ import { z } from "zod"; @@ -19,9 +19,7 @@ export const connectorStatusSchema = z.enum([ export const connectorStatusConfigSchema = z.object({ enabled: z.boolean(), status: connectorStatusSchema, - warning: z.string().nullable().optional(), statusMessage: z.string().nullable().optional(), - disableReason: z.string().nullable().optional(), }); export const connectorStatusMapSchema = z.record(z.string(), connectorStatusConfigSchema); @@ -48,27 +46,21 @@ export type ConnectorStatusConfigFile = z.infer { - return getConnectorStatus(connectorType).warning || null; - }; - /** * Get status message for a connector */ @@ -54,7 +47,6 @@ export function useConnectorStatus() { () => ({ getConnectorStatus, isConnectorEnabled, - getConnectorWarning, getConnectorStatusMessage, shouldShowWarnings, }), diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx index 34bc97f4e..74dd51929 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx @@ -10,7 +10,6 @@ import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types"; import { cn } from "@/lib/utils"; import { getConnectorDisplayName } from "../tabs/all-connectors-tab"; import { useConnectorStatus } from "../hooks/use-connector-status"; -import { ConnectorWarningBanner } from "../components/connector-warning-banner"; interface ConnectorAccountsListViewProps { connectorType: string; @@ -68,13 +67,10 @@ export const ConnectorAccountsListView: FC = ({ isConnecting = false, }) => { // Get connector status - const { isConnectorEnabled, getConnectorWarning, getConnectorStatusMessage, shouldShowWarnings } = - useConnectorStatus(); + const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus(); const isEnabled = isConnectorEnabled(connectorType); - const warning = getConnectorWarning(connectorType); const statusMessage = getConnectorStatusMessage(connectorType); - const showWarnings = shouldShowWarnings(); // Filter connectors to only show those of this type const typeConnectors = connectors.filter((c) => c.connector_type === connectorType); @@ -137,10 +133,6 @@ export const ConnectorAccountsListView: FC = ({ {/* Content */}
- {/* Warning Banner */} - {warning && showWarnings && ( - - )} {/* Connected Accounts Grid */}
{typeConnectors.map((connector) => { From 180fff7105eab9421243bbf133f1a8d1953756fe Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:38:38 +0530 Subject: [PATCH 17/49] feat: enhance connector card and status badge with tooltip support - Added tooltip functionality to `ConnectorCard` for displaying status messages on disabled or maintenance connectors. - Updated `ConnectorStatusBadge` to show status messages in tooltips for warning statuses, improving user feedback. - Refactored rendering logic to ensure tooltips are displayed appropriately based on connector status. --- .../components/connector-card.tsx | 38 +++++++++++++----- .../components/connector-status-badge.tsx | 40 ++++++++++++++++--- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx index a3aee33b0..b5b76c253 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx @@ -5,6 +5,7 @@ import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } f import { FileText, Loader2 } from "lucide-react"; import type { FC } from "react"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { LogActiveTask } from "@/contracts/types/log.types"; import { cn } from "@/lib/utils"; @@ -138,14 +139,8 @@ export const ConnectorCard: FC = ({ ); } - // Priority 1: Show status message if available (for both connected and disconnected connectors) - // This takes precedence over indexed dates and warnings - if (statusMessage) { - return {statusMessage}; - } - if (isConnected) { - // Show last indexed date for connected connectors (only if no status message) + // Show last indexed date for connected connectors if (lastIndexedAt) { return ( @@ -160,7 +155,12 @@ export const ConnectorCard: FC = ({ return description; }; - return ( + // Determine if we should show tooltip on the whole card (for disabled/maintenance) + const shouldShowCardTooltip = + statusMessage && + (status.status === "disabled" || status.status === "maintenance"); + + const cardContent = (
= ({
{title} {showWarnings && status.status !== "active" && ( - + )}
{getStatusContent()}
@@ -239,4 +243,20 @@ export const ConnectorCard: FC = ({
); + + // Wrap card in tooltip for disabled/maintenance status + if (shouldShowCardTooltip) { + return ( + + + {cardContent} + + + {statusMessage} + + + ); + } + + return cardContent; }; diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx index 15e9cadb8..8549ebc47 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx @@ -2,15 +2,21 @@ import { AlertTriangle, Ban, Wrench } from "lucide-react"; import type { FC } from "react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { ConnectorStatus } from "../config/connector-status-config"; import { cn } from "@/lib/utils"; interface ConnectorStatusBadgeProps { status: ConnectorStatus; + statusMessage?: string | null; className?: string; } -export const ConnectorStatusBadge: FC = ({ status, className }) => { +export const ConnectorStatusBadge: FC = ({ + status, + statusMessage, + className, +}) => { if (status === "active") { return null; } @@ -21,25 +27,25 @@ export const ConnectorStatusBadge: FC = ({ status, cl return { icon: AlertTriangle, className: "text-yellow-500 dark:text-yellow-400", - title: "Warning", + defaultTitle: "Warning", }; case "disabled": return { icon: Ban, className: "text-red-500 dark:text-red-400", - title: "Disabled", + defaultTitle: "Disabled", }; case "maintenance": return { icon: Wrench, className: "text-orange-500 dark:text-orange-400", - title: "Maintenance", + defaultTitle: "Maintenance", }; case "deprecated": return { icon: AlertTriangle, className: "text-amber-500 dark:text-amber-400", - title: "Deprecated", + defaultTitle: "Deprecated", }; default: return null; @@ -50,11 +56,33 @@ export const ConnectorStatusBadge: FC = ({ status, cl if (!config) return null; const Icon = config.icon; + // Only show statusMessage in tooltip for warning status + // For disabled/maintenance, the card tooltip will show the statusMessage + const shouldUseTooltip = status === "warning" && statusMessage; + const tooltipTitle = shouldUseTooltip ? statusMessage : config.defaultTitle; + + // Use Tooltip component for warning status with statusMessage, native title for others + if (shouldUseTooltip) { + return ( + + + + + + + + {statusMessage} + + + ); + } return ( From 2c3d625b35613a38bb3e58aba359af917afa163a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 9 Jan 2026 20:11:47 +0200 Subject: [PATCH 18/49] fix: increase top_k from 5 to 10 to match knowledge base --- .../app/agents/new_chat/tools/search_surfsense_docs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py b/surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py index 21f3942ab..a34e16ff2 100644 --- a/surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py +++ b/surfsense_backend/app/agents/new_chat/tools/search_surfsense_docs.py @@ -82,7 +82,7 @@ def format_surfsense_docs_results(results: list[tuple]) -> str: async def search_surfsense_docs_async( query: str, db_session: AsyncSession, - top_k: int = 5, + top_k: int = 10, ) -> str: """ Search Surfsense documentation using vector similarity. @@ -127,7 +127,7 @@ def create_search_surfsense_docs_tool(db_session: AsyncSession): """ @tool - async def search_surfsense_docs(query: str, top_k: int = 5) -> str: + async def search_surfsense_docs(query: str, top_k: int = 10) -> str: """ Search Surfsense documentation for help with using the application. @@ -145,7 +145,7 @@ def create_search_surfsense_docs_tool(db_session: AsyncSession): Args: query: The search query about Surfsense usage or features - top_k: Number of documentation chunks to retrieve (default: 5) + top_k: Number of documentation chunks to retrieve (default: 10) Returns: Relevant documentation content formatted with chunk IDs for citations From f52440977fba3605645177748e0bb145e385c362 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:08:23 +0530 Subject: [PATCH 19/49] fix: disable breadcrumb on mobile view --- surfsense_web/components/layout/ui/header/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx index a03761ef5..0bdb9b423 100644 --- a/surfsense_web/components/layout/ui/header/Header.tsx +++ b/surfsense_web/components/layout/ui/header/Header.tsx @@ -24,7 +24,7 @@ export function Header({ {/* Left side - Mobile menu trigger + Breadcrumb */}
{mobileMenuTrigger} - {breadcrumb} +
{breadcrumb}
{/* Right side - Actions */} From 075bb44731952f9dc400d72b11c45f16e3e86a31 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:12:28 +0530 Subject: [PATCH 20/49] chore: ran frontend linting --- .../connector-popup/components/connector-card.tsx | 15 ++++----------- .../components/connector-status-badge.tsx | 4 +--- .../config/connector-status-config.ts | 6 +++--- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx index b5b76c253..a9a33e8b3 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx @@ -108,12 +108,8 @@ export const ConnectorCard: FC = ({ onManage, }) => { // Get connector status - const { - getConnectorStatus, - isConnectorEnabled, - getConnectorStatusMessage, - shouldShowWarnings, - } = useConnectorStatus(); + const { getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings } = + useConnectorStatus(); const status = getConnectorStatus(connectorType); const isEnabled = isConnectorEnabled(connectorType); @@ -157,8 +153,7 @@ export const ConnectorCard: FC = ({ // Determine if we should show tooltip on the whole card (for disabled/maintenance) const shouldShowCardTooltip = - statusMessage && - (status.status === "disabled" || status.status === "maintenance"); + statusMessage && (status.status === "disabled" || status.status === "maintenance"); const cardContent = (
= ({ if (shouldShowCardTooltip) { return ( - - {cardContent} - + {cardContent} {statusMessage} diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx index 8549ebc47..4b83292bd 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx @@ -66,9 +66,7 @@ export const ConnectorStatusBadge: FC = ({ return ( - + diff --git a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts index e6586e110..42d5c4d76 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts @@ -46,17 +46,17 @@ export type ConnectorStatusConfigFile = z.infer Date: Sun, 11 Jan 2026 16:13:48 +0530 Subject: [PATCH 21/49] refactor: update connector status configuration to use commented example configs --- .../config/connector-status-config.ts | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts index 42d5c4d76..a3745df48 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts @@ -46,21 +46,22 @@ export type ConnectorStatusConfigFile = z.infer Date: Sun, 11 Jan 2026 16:53:54 +0530 Subject: [PATCH 22/49] feat: add json files for status badges --- .../components/connector-card.tsx | 33 +++------------ .../components/connector-status-badge.tsx | 11 ++--- .../connector-status-config.example.json | 29 ++++++++++++++ .../config/connector-status-config.json | 10 +++++ .../config/connector-status-config.ts | 40 +++---------------- 5 files changed, 57 insertions(+), 66 deletions(-) create mode 100644 surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.example.json create mode 100644 surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.json diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx index a9a33e8b3..fa4b8feb6 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx @@ -5,7 +5,6 @@ import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } f import { FileText, Loader2 } from "lucide-react"; import type { FC } from "react"; import { Button } from "@/components/ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { LogActiveTask } from "@/contracts/types/log.types"; import { cn } from "@/lib/utils"; @@ -151,29 +150,21 @@ export const ConnectorCard: FC = ({ return description; }; - // Determine if we should show tooltip on the whole card (for disabled/maintenance) - const shouldShowCardTooltip = - statusMessage && (status.status === "disabled" || status.status === "maintenance"); - const cardContent = (
{connectorType ? ( @@ -239,17 +230,5 @@ export const ConnectorCard: FC = ({
); - // Wrap card in tooltip for disabled/maintenance status - if (shouldShowCardTooltip) { - return ( - - {cardContent} - - {statusMessage} - - - ); - } - return cardContent; }; diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx index 4b83292bd..a5fd0c331 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx @@ -44,7 +44,7 @@ export const ConnectorStatusBadge: FC = ({ case "deprecated": return { icon: AlertTriangle, - className: "text-amber-500 dark:text-amber-400", + className: "ext-slate-500 dark:text-slate-400", defaultTitle: "Deprecated", }; default: @@ -56,12 +56,13 @@ export const ConnectorStatusBadge: FC = ({ if (!config) return null; const Icon = config.icon; - // Only show statusMessage in tooltip for warning status - // For disabled/maintenance, the card tooltip will show the statusMessage - const shouldUseTooltip = status === "warning" && statusMessage; + // Show statusMessage in tooltip for warning, deprecated, disabled, and maintenance statuses + const shouldUseTooltip = + (status === "warning" || status === "deprecated" || status === "disabled" || status === "maintenance") && + statusMessage; const tooltipTitle = shouldUseTooltip ? statusMessage : config.defaultTitle; - // Use Tooltip component for warning status with statusMessage, native title for others + // Use Tooltip component for statuses with statusMessage, native title for others if (shouldUseTooltip) { return ( diff --git a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.example.json b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.example.json new file mode 100644 index 000000000..e239e3e23 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.example.json @@ -0,0 +1,29 @@ +{ + "connectorStatuses": { + "SLACK_CONNECTOR": { + "enabled": false, + "status": "disabled", + "statusMessage": "Unavailable due to API changes" + }, + "NOTION_CONNECTOR": { + "enabled": true, + "status": "warning", + "statusMessage": "Rate limits may apply" + }, + "TEAMS_CONNECTOR": { + "enabled": false, + "status": "maintenance", + "statusMessage": "Temporarily unavailable for maintenance" + }, + "JIRA_CONNECTOR": { + "enabled": false, + "status": "deprecated", + "statusMessage": "Deprecated" + } + }, + "globalSettings": { + "showWarnings": true, + "allowManualOverride": false + } +} + diff --git a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.json b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.json new file mode 100644 index 000000000..13b227a16 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.json @@ -0,0 +1,10 @@ +{ + "connectorStatuses": { + + }, + "globalSettings": { + "showWarnings": true, + "allowManualOverride": false + } +} + diff --git a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts index a3745df48..06e98d927 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.ts @@ -1,11 +1,13 @@ /** * Connector Status Configuration * - * This configuration allows managing connector statuses. - * Statuses control disabling connectors and displaying status messages. + * Manages connector statuses (disable/enable, status messages). Edit connector-status-config.json to configure. + * Valid status values: "active", "warning", "disabled", "deprecated", "maintenance". + * Unlisted connectors default to "active" and enabled. See connector-status-config.example.json for reference. */ import { z } from "zod"; +import rawConnectorStatusConfigData from "./connector-status-config.json"; // Zod schemas for runtime validation and type safety export const connectorStatusSchema = z.enum([ @@ -39,40 +41,10 @@ export type ConnectorStatusMap = z.infer; export type ConnectorStatusConfigFile = z.infer; /** - * Default status configuration for all connectors - * Connectors not listed here default to "active" and enabled - * - * This config is validated at runtime using the Zod schema above + * Validated at runtime via Zod schema; invalid JSON throws at module load time. */ -const rawConnectorStatusConfig = { - connectorStatuses: { - // Example configs to use - // SLACK_CONNECTOR: { - // enabled: false, - // status: "disabled", - // statusMessage: "Unavailable due to API changes", - // }, - // NOTION_CONNECTOR: { - // enabled: true, - // status: "warning", - // statusMessage: "Rate limits may apply", - // }, - // TEAMS_CONNECTOR: { - // enabled: false, - // status: "maintenance", - // statusMessage: "Temporarily unavailable for maintenance", - // }, - }, - globalSettings: { - showWarnings: true, - allowManualOverride: false, - }, -}; - -// Validate the config at module load time (development only) -// In production, this will throw if config is invalid export const connectorStatusConfig: ConnectorStatusConfigFile = - connectorStatusConfigFileSchema.parse(rawConnectorStatusConfig); + connectorStatusConfigFileSchema.parse(rawConnectorStatusConfigData); /** * Get default status config for a connector (when not in config file) From b53d095ab9a11fd3ad10e7fc17e304dae545078c Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:56:11 +0530 Subject: [PATCH 23/49] chore: ran frontend linting --- .../connector-popup/components/connector-status-badge.tsx | 5 ++++- .../config/connector-status-config.example.json | 1 - .../connector-popup/config/connector-status-config.json | 5 +---- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx index a5fd0c331..ecc3a11cd 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-status-badge.tsx @@ -58,7 +58,10 @@ export const ConnectorStatusBadge: FC = ({ const Icon = config.icon; // Show statusMessage in tooltip for warning, deprecated, disabled, and maintenance statuses const shouldUseTooltip = - (status === "warning" || status === "deprecated" || status === "disabled" || status === "maintenance") && + (status === "warning" || + status === "deprecated" || + status === "disabled" || + status === "maintenance") && statusMessage; const tooltipTitle = shouldUseTooltip ? statusMessage : config.defaultTitle; diff --git a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.example.json b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.example.json index e239e3e23..ad2a914f3 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.example.json +++ b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.example.json @@ -26,4 +26,3 @@ "allowManualOverride": false } } - diff --git a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.json b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.json index 13b227a16..470ff22e9 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.json +++ b/surfsense_web/components/assistant-ui/connector-popup/config/connector-status-config.json @@ -1,10 +1,7 @@ { - "connectorStatuses": { - - }, + "connectorStatuses": {}, "globalSettings": { "showWarnings": true, "allowManualOverride": false } } - From eed04e9b278369bab390d91a07728136296acaec Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 12 Jan 2026 15:47:56 +0200 Subject: [PATCH 24/49] refactor: rename Workspace to SearchSpace in layout components --- .../components/clickup-connect-form.tsx | 385 ++++++++++++++++++ .../components/teams-config 2.tsx | 29 ++ .../views/connector-accounts-list-view 2.tsx | 189 +++++++++ surfsense_web/components/layout/index.ts | 4 +- .../layout/providers/LayoutDataProvider.tsx | 32 +- .../components/layout/types/layout.types.ts | 30 +- .../layout/ui/icon-rail/IconRail.tsx | 38 +- .../layout/ui/icon-rail/SearchSpaceAvatar.tsx | 72 ++++ .../layout/ui/icon-rail/WorkspaceAvatar.tsx | 8 +- .../components/layout/ui/icon-rail/index.ts | 2 +- surfsense_web/components/layout/ui/index.ts | 2 +- .../layout/ui/shell/LayoutShell.tsx | 58 +-- .../layout/ui/sidebar/MobileSidebar.tsx | 48 +-- .../components/layout/ui/sidebar/Sidebar.tsx | 22 +- .../layout/ui/sidebar/SidebarHeader.tsx | 30 +- surfsense_web/messages/en.json | 4 + surfsense_web/messages/zh.json | 4 + 17 files changed, 820 insertions(+), 137 deletions(-) create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/clickup-connect-form.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/teams-config 2.tsx create mode 100644 surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view 2.tsx create mode 100644 surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/clickup-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/clickup-connect-form.tsx new file mode 100644 index 000000000..9f33c6ed9 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/clickup-connect-form.tsx @@ -0,0 +1,385 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Info } from "lucide-react"; +import type { FC } from "react"; +import { useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { EnumConnectorName } from "@/contracts/enums/connector"; +import { DateRangeSelector } from "../../components/date-range-selector"; +import { getConnectorBenefits } from "../connector-benefits"; +import type { ConnectFormProps } from "../index"; + +const clickupConnectorFormSchema = z.object({ + name: z.string().min(3, { + message: "Connector name must be at least 3 characters.", + }), + api_token: z.string().min(10, { + message: "ClickUp API Token is required and must be valid.", + }), +}); + +type ClickUpConnectorFormValues = z.infer; + +export const ClickUpConnectForm: FC = ({ onSubmit, isSubmitting }) => { + const isSubmittingRef = useRef(false); + const [startDate, setStartDate] = useState(undefined); + const [endDate, setEndDate] = useState(undefined); + const [periodicEnabled, setPeriodicEnabled] = useState(false); + const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); + const form = useForm({ + resolver: zodResolver(clickupConnectorFormSchema), + defaultValues: { + name: "ClickUp Connector", + api_token: "", + }, + }); + + const handleSubmit = async (values: ClickUpConnectorFormValues) => { + // Prevent multiple submissions + if (isSubmittingRef.current || isSubmitting) { + return; + } + + isSubmittingRef.current = true; + try { + await onSubmit({ + name: values.name, + connector_type: EnumConnectorName.CLICKUP_CONNECTOR, + config: { + CLICKUP_API_TOKEN: values.api_token, + }, + is_indexable: true, + last_indexed_at: null, + periodic_indexing_enabled: periodicEnabled, + indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null, + next_scheduled_at: null, + startDate, + endDate, + periodicEnabled, + frequencyMinutes, + }); + } finally { + isSubmittingRef.current = false; + } + }; + + return ( +
+ + +
+ API Token Required + + You'll need a ClickUp API Token to use this connector. You can create one from{" "} + + ClickUp Settings + + +
+
+ +
+
+ + ( + + Connector Name + + + + + A friendly name to identify this connector. + + + + )} + /> + + ( + + ClickUp API Token + + + + + Your ClickUp API Token will be encrypted and stored securely. + + + + )} + /> + + {/* Indexing Configuration */} +
+

Indexing Configuration

+ + {/* Date Range Selector */} + + + {/* Periodic Sync Config */} +
+
+
+

Enable Periodic Sync

+

+ Automatically re-index at regular intervals +

+
+ +
+ + {periodicEnabled && ( +
+
+ + +
+
+ )} +
+
+ + +
+ + {/* What you get section */} + {getConnectorBenefits(EnumConnectorName.CLICKUP_CONNECTOR) && ( +
+

What you get with ClickUp integration:

+
    + {getConnectorBenefits(EnumConnectorName.CLICKUP_CONNECTOR)?.map((benefit) => ( +
  • {benefit}
  • + ))} +
+
+ )} + + {/* Documentation Section */} + + + + Documentation + + +
+

How it works

+

+ The ClickUp connector uses the ClickUp API to fetch all tasks and projects that your + API token has access to within your workspace. +

+
    +
  • + For follow up indexing runs, the connector retrieves tasks that have been updated + since the last indexing attempt. +
  • +
  • + Indexing is configured to run periodically, so updates should appear in your + search results within minutes. +
  • +
+
+ +
+
+

Authorization

+ + + API Token Required + + You need a ClickUp personal API token to use this connector. The token will be + used to read your ClickUp data. + + + +
+
+

+ Step 1: Get Your API Token +

+
    +
  1. Log in to your ClickUp account
  2. +
  3. Click your avatar in the upper-right corner and select "Settings"
  4. +
  5. In the sidebar, click "Apps"
  6. +
  7. + Under "API Token", click Generate or{" "} + Regenerate +
  8. +
  9. Copy the generated token (it typically starts with "pk_")
  10. +
  11. + Paste it in the form above. You can also visit{" "} + + ClickUp API Settings + {" "} + directly. +
  12. +
+
+ +
+

+ Step 2: Grant necessary access +

+

+ The API Token will have access to all tasks and projects that your user + account can see. Make sure your account has appropriate permissions for the + workspaces you want to index. +

+ + + Data Privacy + + Only tasks, comments, and basic metadata will be indexed. ClickUp + attachments and linked files are not indexed by this connector. + + +
+
+
+
+ +
+
+

Indexing

+
    +
  1. + Navigate to the Connector Dashboard and select the ClickUp{" "} + Connector. +
  2. +
  3. + Place your API Token in the form field. +
  4. +
  5. + Click Connect to establish the connection. +
  6. +
  7. Once connected, your ClickUp tasks will be indexed automatically.
  8. +
+ + + + What Gets Indexed + +

The ClickUp connector indexes the following data:

+
    +
  • Task names and descriptions
  • +
  • Task comments and discussion threads
  • +
  • Task status, priority, and assignee information
  • +
  • Project and workspace information
  • +
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/teams-config 2.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/teams-config 2.tsx new file mode 100644 index 000000000..ac08a6c03 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/teams-config 2.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Info } from "lucide-react"; +import type { FC } from "react"; +import type { ConnectorConfigProps } from "../index"; + +export interface TeamsConfigProps extends ConnectorConfigProps { + onNameChange?: (name: string) => void; +} + +export const TeamsConfig: FC = () => { + return ( +
+
+
+ +
+
+

Microsoft Teams Access

+

+ SurfSense will index messages from Teams channels that you have access to. The app can + only read messages from teams and channels where you are a member. Make sure you're a + member of the teams you want to index before connecting. +

+
+
+
+ ); +}; diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view 2.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view 2.tsx new file mode 100644 index 000000000..e45f24d11 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view 2.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns"; +import { ArrowLeft, Loader2, Plus } from "lucide-react"; +import type { FC } from "react"; +import { Button } from "@/components/ui/button"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types"; +import { cn } from "@/lib/utils"; +import { getConnectorDisplayName } from "../tabs/all-connectors-tab"; + +interface ConnectorAccountsListViewProps { + connectorType: string; + connectorTitle: string; + connectors: SearchSourceConnector[]; + indexingConnectorIds: Set; + logsSummary: LogSummary | undefined; + onBack: () => void; + onManage: (connector: SearchSourceConnector) => void; + onAddAccount: () => void; + isConnecting?: boolean; +} + +/** + * Format last indexed date with contextual messages + */ +function formatLastIndexedDate(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const minutesAgo = differenceInMinutes(now, date); + const daysAgo = differenceInDays(now, date); + + if (minutesAgo < 1) { + return "Just now"; + } + + if (minutesAgo < 60) { + return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`; + } + + if (isToday(date)) { + return `Today at ${format(date, "h:mm a")}`; + } + + if (isYesterday(date)) { + return `Yesterday at ${format(date, "h:mm a")}`; + } + + if (daysAgo < 7) { + return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`; + } + + return format(date, "MMM d, yyyy"); +} + +export const ConnectorAccountsListView: FC = ({ + connectorType, + connectorTitle, + connectors, + indexingConnectorIds, + logsSummary, + onBack, + onManage, + onAddAccount, + isConnecting = false, +}) => { + // Filter connectors to only show those of this type + const typeConnectors = connectors.filter((c) => c.connector_type === connectorType); + + return ( +
+ {/* Header */} +
+
+
+ +
+
+ {getConnectorIcon(connectorType, "size-5")} +
+
+

{connectorTitle} Accounts

+

+ {typeConnectors.length} connected account{typeConnectors.length !== 1 ? "s" : ""} +

+
+
+
+ {/* Add Account Button with dashed border */} + +
+
+ + {/* Content */} +
+ {/* Connected Accounts Grid */} +
+ {typeConnectors.map((connector) => { + const isIndexing = indexingConnectorIds.has(connector.id); + const activeTask = logsSummary?.active_tasks?.find( + (task: LogActiveTask) => task.connector_id === connector.id + ); + + return ( +
+
+ {getConnectorIcon(connector.connector_type, "size-6")} +
+
+

+ {getConnectorDisplayName(connector.name)} +

+ {isIndexing ? ( +

+ + Indexing... + {activeTask?.message && ( + + • {activeTask.message} + + )} +

+ ) : ( +

+ {connector.last_indexed_at + ? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}` + : "Never indexed"} +

+ )} +
+ +
+ ); + })} +
+
+
+ ); +}; diff --git a/surfsense_web/components/layout/index.ts b/surfsense_web/components/layout/index.ts index 745075b6f..4fe2975c1 100644 --- a/surfsense_web/components/layout/index.ts +++ b/surfsense_web/components/layout/index.ts @@ -8,7 +8,7 @@ export type { PageUsage, SidebarSectionProps, User, - Workspace, + SearchSpace, } from "./types/layout.types"; export { ChatListItem, @@ -26,5 +26,5 @@ export { SidebarHeader, SidebarSection, SidebarUserProfile, - WorkspaceAvatar, + SearchSpaceAvatar, } from "./ui"; diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index ea750a365..b54f2b2fd 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -25,7 +25,7 @@ import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence"; import { resetUser, trackLogout } from "@/lib/posthog/events"; import { cacheKeys } from "@/lib/query-client/cache-keys"; -import type { ChatItem, NavItem, NoteItem, Workspace } from "../types/layout.types"; +import type { ChatItem, NavItem, NoteItem, SearchSpace } from "../types/layout.types"; import { LayoutShell } from "../ui/shell"; import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar"; import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar"; @@ -123,8 +123,8 @@ export function LayoutDataProvider({ } | null>(null); const [isDeletingNote, setIsDeletingNote] = useState(false); - // Transform workspaces (API returns array directly, not { items: [...] }) - const workspaces: Workspace[] = useMemo(() => { + // Transform search spaces (API returns array directly, not { items: [...] }) + const searchSpaces: SearchSpace[] = useMemo(() => { if (!searchSpacesData || !Array.isArray(searchSpacesData)) return []; return searchSpacesData.map((space) => ({ id: space.id, @@ -135,8 +135,8 @@ export function LayoutDataProvider({ })); }, [searchSpacesData]); - // Use searchSpace query result for current workspace (more reliable than finding in list) - const activeWorkspace: Workspace | null = searchSpace + // Use searchSpace query result for active search space (more reliable than finding in list) + const activeSearchSpace: SearchSpace | null = searchSpace ? { id: searchSpace.id, name: searchSpace.name, @@ -196,18 +196,18 @@ export function LayoutDataProvider({ ); // Handlers - const handleWorkspaceSelect = useCallback( + const handleSearchSpaceSelect = useCallback( (id: number) => { router.push(`/dashboard/${id}/new-chat`); }, [router] ); - const handleAddWorkspace = useCallback(() => { + const handleAddSearchSpace = useCallback(() => { router.push("/dashboard/searchspaces"); }, [router]); - const handleSeeAllWorkspaces = useCallback(() => { + const handleSeeAllSearchSpaces = useCallback(() => { router.push("/dashboard"); }, [router]); @@ -266,7 +266,7 @@ export function LayoutDataProvider({ router.push(`/dashboard/${searchSpaceId}/settings`); }, [router, searchSpaceId]); - const handleInviteMembers = useCallback(() => { + const handleManageMembers = useCallback(() => { router.push(`/dashboard/${searchSpaceId}/team`); }, [router, searchSpaceId]); @@ -347,11 +347,11 @@ export function LayoutDataProvider({ return ( <> void; - onAddWorkspace: () => void; + searchSpaces: SearchSpace[]; + activeSearchSpaceId: number | null; + onSearchSpaceSelect: (id: number) => void; + onAddSearchSpace: () => void; className?: string; } export interface SidebarHeaderProps { - workspace: Workspace | null; + searchSpace: SearchSpace | null; onSettings?: () => void; } @@ -94,15 +94,15 @@ export interface SidebarUserProfileProps { user: User; searchSpaceId?: string; onSettings?: () => void; - onInviteMembers?: () => void; - onSwitchWorkspace?: () => void; + onManageMembers?: () => void; + onSwitchSearchSpace?: () => void; onToggleTheme?: () => void; onLogout?: () => void; theme?: string; } export interface SidebarProps { - workspace: Workspace | null; + searchSpace: SearchSpace | null; searchSpaceId?: string; navItems: NavItem[]; chats: ChatItem[]; @@ -120,8 +120,8 @@ export interface SidebarProps { user: User; theme?: string; onSettings?: () => void; - onInviteMembers?: () => void; - onSwitchWorkspace?: () => void; + onManageMembers?: () => void; + onSeeAllSearchSpaces?: () => void; onToggleTheme?: () => void; onLogout?: () => void; pageUsage?: PageUsage; @@ -129,10 +129,10 @@ export interface SidebarProps { } export interface LayoutShellProps { - workspaces: Workspace[]; - activeWorkspaceId: number | null; - onWorkspaceSelect: (id: number) => void; - onAddWorkspace: () => void; + searchSpaces: SearchSpace[]; + activeSearchSpaceId: number | null; + onSearchSpaceSelect: (id: number) => void; + onAddSearchSpace: () => void; sidebarProps: Omit; children: React.ReactNode; className?: string; diff --git a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx index 0d6b39cdc..3e8b14ba9 100644 --- a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx +++ b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx @@ -5,34 +5,34 @@ import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import type { Workspace } from "../../types/layout.types"; -import { WorkspaceAvatar } from "./WorkspaceAvatar"; +import type { SearchSpace } from "../../types/layout.types"; +import { SearchSpaceAvatar } from "./SearchSpaceAvatar"; interface IconRailProps { - workspaces: Workspace[]; - activeWorkspaceId: number | null; - onWorkspaceSelect: (id: number) => void; - onAddWorkspace: () => void; + searchSpaces: SearchSpace[]; + activeSearchSpaceId: number | null; + onSearchSpaceSelect: (id: number) => void; + onAddSearchSpace: () => void; className?: string; } export function IconRail({ - workspaces, - activeWorkspaceId, - onWorkspaceSelect, - onAddWorkspace, + searchSpaces, + activeSearchSpaceId, + onSearchSpaceSelect, + onAddSearchSpace, className, }: IconRailProps) { return (
- {workspaces.map((workspace) => ( - onWorkspaceSelect(workspace.id)} + {searchSpaces.map((searchSpace) => ( + onSearchSpaceSelect(searchSpace.id)} size="md" /> ))} @@ -42,15 +42,15 @@ export function IconRail({ - Add workspace + Add search space
diff --git a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx new file mode 100644 index 000000000..397076cb6 --- /dev/null +++ b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +interface SearchSpaceAvatarProps { + name: string; + isActive?: boolean; + onClick?: () => void; + size?: "sm" | "md"; +} + +/** + * Generates a consistent color based on search space name + */ +function stringToColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const colors = [ + "#6366f1", // indigo + "#22c55e", // green + "#f59e0b", // amber + "#ef4444", // red + "#8b5cf6", // violet + "#06b6d4", // cyan + "#ec4899", // pink + "#14b8a6", // teal + ]; + return colors[Math.abs(hash) % colors.length]; +} + +/** + * Gets initials from search space name (max 2 chars) + */ +function getInitials(name: string): string { + const words = name.trim().split(/\s+/); + if (words.length >= 2) { + return (words[0][0] + words[1][0]).toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); +} + +export function SearchSpaceAvatar({ name, isActive, onClick, size = "md" }: SearchSpaceAvatarProps) { + const bgColor = stringToColor(name); + const initials = getInitials(name); + const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm"; + + return ( + + + + + + {name} + + + ); +} diff --git a/surfsense_web/components/layout/ui/icon-rail/WorkspaceAvatar.tsx b/surfsense_web/components/layout/ui/icon-rail/WorkspaceAvatar.tsx index 1c4798d2a..397076cb6 100644 --- a/surfsense_web/components/layout/ui/icon-rail/WorkspaceAvatar.tsx +++ b/surfsense_web/components/layout/ui/icon-rail/WorkspaceAvatar.tsx @@ -3,7 +3,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -interface WorkspaceAvatarProps { +interface SearchSpaceAvatarProps { name: string; isActive?: boolean; onClick?: () => void; @@ -11,7 +11,7 @@ interface WorkspaceAvatarProps { } /** - * Generates a consistent color based on workspace name + * Generates a consistent color based on search space name */ function stringToColor(str: string): string { let hash = 0; @@ -32,7 +32,7 @@ function stringToColor(str: string): string { } /** - * Gets initials from workspace name (max 2 chars) + * Gets initials from search space name (max 2 chars) */ function getInitials(name: string): string { const words = name.trim().split(/\s+/); @@ -42,7 +42,7 @@ function getInitials(name: string): string { return name.slice(0, 2).toUpperCase(); } -export function WorkspaceAvatar({ name, isActive, onClick, size = "md" }: WorkspaceAvatarProps) { +export function SearchSpaceAvatar({ name, isActive, onClick, size = "md" }: SearchSpaceAvatarProps) { const bgColor = stringToColor(name); const initials = getInitials(name); const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm"; diff --git a/surfsense_web/components/layout/ui/icon-rail/index.ts b/surfsense_web/components/layout/ui/icon-rail/index.ts index 0e7e8cd29..b635e7273 100644 --- a/surfsense_web/components/layout/ui/icon-rail/index.ts +++ b/surfsense_web/components/layout/ui/icon-rail/index.ts @@ -1,3 +1,3 @@ export { IconRail } from "./IconRail"; export { NavIcon } from "./NavIcon"; -export { WorkspaceAvatar } from "./WorkspaceAvatar"; +export { SearchSpaceAvatar } from "./SearchSpaceAvatar"; diff --git a/surfsense_web/components/layout/ui/index.ts b/surfsense_web/components/layout/ui/index.ts index 74b1e9240..31e288561 100644 --- a/surfsense_web/components/layout/ui/index.ts +++ b/surfsense_web/components/layout/ui/index.ts @@ -1,5 +1,5 @@ export { Header } from "./header"; -export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail"; +export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail"; export { LayoutShell } from "./shell"; export { ChatListItem, diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 0d7b24113..50f963fb9 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -11,18 +11,18 @@ import type { NoteItem, PageUsage, User, - Workspace, + SearchSpace, } from "../../types/layout.types"; import { Header } from "../header"; import { IconRail } from "../icon-rail"; import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar"; interface LayoutShellProps { - workspaces: Workspace[]; - activeWorkspaceId: number | null; - onWorkspaceSelect: (id: number) => void; - onAddWorkspace: () => void; - workspace: Workspace | null; + searchSpaces: SearchSpace[]; + activeSearchSpaceId: number | null; + onSearchSpaceSelect: (id: number) => void; + onAddSearchSpace: () => void; + searchSpace: SearchSpace | null; navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; chats: ChatItem[]; @@ -39,8 +39,8 @@ interface LayoutShellProps { onViewAllNotes?: () => void; user: User; onSettings?: () => void; - onInviteMembers?: () => void; - onSeeAllWorkspaces?: () => void; + onManageMembers?: () => void; + onSeeAllSearchSpaces?: () => void; onLogout?: () => void; pageUsage?: PageUsage; breadcrumb?: React.ReactNode; @@ -54,11 +54,11 @@ interface LayoutShellProps { } export function LayoutShell({ - workspaces, - activeWorkspaceId, - onWorkspaceSelect, - onAddWorkspace, - workspace, + searchSpaces, + activeSearchSpaceId, + onSearchSpaceSelect, + onAddSearchSpace, + searchSpace, navItems, onNavItemClick, chats, @@ -75,8 +75,8 @@ export function LayoutShell({ onViewAllNotes, user, onSettings, - onInviteMembers, - onSeeAllWorkspaces, + onManageMembers, + onSeeAllSearchSpaces, onLogout, pageUsage, breadcrumb, @@ -108,11 +108,11 @@ export function LayoutShell({ @@ -149,16 +149,16 @@ export function LayoutShell({
void; - workspaces: Workspace[]; - activeWorkspaceId: number | null; - onWorkspaceSelect: (id: number) => void; - onAddWorkspace: () => void; - workspace: Workspace | null; + searchSpaces: SearchSpace[]; + activeSearchSpaceId: number | null; + onSearchSpaceSelect: (id: number) => void; + onAddSearchSpace: () => void; + searchSpace: SearchSpace | null; navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; chats: ChatItem[]; @@ -39,8 +39,8 @@ interface MobileSidebarProps { onViewAllNotes?: () => void; user: User; onSettings?: () => void; - onInviteMembers?: () => void; - onSeeAllWorkspaces?: () => void; + onManageMembers?: () => void; + onSeeAllSearchSpaces?: () => void; onLogout?: () => void; pageUsage?: PageUsage; } @@ -57,11 +57,11 @@ export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) { export function MobileSidebar({ isOpen, onOpenChange, - workspaces, - activeWorkspaceId, - onWorkspaceSelect, - onAddWorkspace, - workspace, + searchSpaces, + activeSearchSpaceId, + onSearchSpaceSelect, + onAddSearchSpace, + searchSpace, navItems, onNavItemClick, chats, @@ -78,13 +78,13 @@ export function MobileSidebar({ onViewAllNotes, user, onSettings, - onInviteMembers, - onSeeAllWorkspaces, + onManageMembers, + onSeeAllSearchSpaces, onLogout, pageUsage, }: MobileSidebarProps) { - const handleWorkspaceSelect = (id: number) => { - onWorkspaceSelect(id); + const handleSearchSpaceSelect = (id: number) => { + onSearchSpaceSelect(id); }; const handleNavItemClick = (item: NavItem) => { @@ -110,17 +110,17 @@ export function MobileSidebar({
void; navItems: NavItem[]; @@ -43,15 +43,15 @@ interface SidebarProps { onViewAllNotes?: () => void; user: User; onSettings?: () => void; - onInviteMembers?: () => void; - onSeeAllWorkspaces?: () => void; + onManageMembers?: () => void; + onSeeAllSearchSpaces?: () => void; onLogout?: () => void; pageUsage?: PageUsage; className?: string; } export function Sidebar({ - workspace, + searchSpace, isCollapsed = false, onToggleCollapse, navItems, @@ -70,8 +70,8 @@ export function Sidebar({ onViewAllNotes, user, onSettings, - onInviteMembers, - onSeeAllWorkspaces, + onManageMembers, + onSeeAllSearchSpaces, onLogout, pageUsage, className, @@ -86,7 +86,7 @@ export function Sidebar({ className )} > - {/* Header - workspace name or collapse button when collapsed */} + {/* Header - search space name or collapse button when collapsed */} {isCollapsed ? (
void; - onInviteMembers?: () => void; - onSeeAllWorkspaces?: () => void; + onManageMembers?: () => void; + onSeeAllSearchSpaces?: () => void; className?: string; } export function SidebarHeader({ - workspace, + searchSpace, isCollapsed, onSettings, - onInviteMembers, - onSeeAllWorkspaces, + onManageMembers, + onSeeAllSearchSpaces, className, }: SidebarHeaderProps) { const t = useTranslations("sidebar"); @@ -43,24 +43,24 @@ export function SidebarHeader({ isCollapsed ? "w-10" : "w-50" )} > - {workspace?.name ?? t("select_workspace")} + {searchSpace?.name ?? t("select_search_space")} - - - {t("invite_members")} + + + {t("manage_members")} - {t("workspace_settings")} + {t("search_space_settings")} - + - {t("see_all_workspaces")} + {t("see_all_search_spaces")} diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index b803d4b69..8b0164211 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -625,9 +625,13 @@ "error_archiving_chat": "Failed to archive chat", "new_chat": "New chat", "select_workspace": "Select Workspace", + "select_search_space": "Select Search Space", "invite_members": "Invite members", + "manage_members": "Manage members", "workspace_settings": "Workspace settings", + "search_space_settings": "Search space settings", "see_all_workspaces": "See all search spaces", + "see_all_search_spaces": "See all search spaces", "expand_sidebar": "Expand sidebar", "collapse_sidebar": "Collapse sidebar", "logout": "Logout" diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index fa690bf39..ee04baad5 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -619,9 +619,13 @@ "add_note": "添加笔记", "new_chat": "新对话", "select_workspace": "选择工作空间", + "select_search_space": "选择搜索空间", "invite_members": "邀请成员", + "manage_members": "管理成员", "workspace_settings": "工作空间设置", + "search_space_settings": "搜索空间设置", "see_all_workspaces": "查看所有搜索空间", + "see_all_search_spaces": "查看所有搜索空间", "expand_sidebar": "展开侧边栏", "collapse_sidebar": "收起侧边栏", "logout": "退出登录" From d5580fe3ac5405c4ee53bf1b6cbb6f8fe29413b0 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 12 Jan 2026 15:56:23 +0200 Subject: [PATCH 25/49] Add CreateSearchSpaceDialog component --- surfsense_web/components/layout/index.ts | 1 + .../ui/dialogs/CreateSearchSpaceDialog.tsx | 174 ++++++++++++++++++ .../components/layout/ui/dialogs/index.ts | 2 + surfsense_web/components/layout/ui/index.ts | 1 + surfsense_web/messages/en.json | 10 + surfsense_web/messages/zh.json | 10 + 6 files changed, 198 insertions(+) create mode 100644 surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx create mode 100644 surfsense_web/components/layout/ui/dialogs/index.ts diff --git a/surfsense_web/components/layout/index.ts b/surfsense_web/components/layout/index.ts index 4fe2975c1..3a79ccde7 100644 --- a/surfsense_web/components/layout/index.ts +++ b/surfsense_web/components/layout/index.ts @@ -12,6 +12,7 @@ export type { } from "./types/layout.types"; export { ChatListItem, + CreateSearchSpaceDialog, Header, IconRail, LayoutShell, diff --git a/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx b/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx new file mode 100644 index 000000000..94646d739 --- /dev/null +++ b/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useAtomValue } from "jotai"; +import { Loader2, Plus, Search } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { trackSearchSpaceCreated } from "@/lib/posthog/events"; + +const formSchema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), +}); + +type FormValues = z.infer; + +interface CreateSearchSpaceDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpaceDialogProps) { + const t = useTranslations("searchSpace"); + const tCommon = useTranslations("common"); + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { mutateAsync: createSearchSpace } = useAtomValue(createSearchSpaceMutationAtom); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + description: "", + }, + }); + + const handleSubmit = async (values: FormValues) => { + setIsSubmitting(true); + try { + const result = await createSearchSpace({ + name: values.name, + description: values.description || "", + }); + + // Track search space creation + trackSearchSpaceCreated(result.id, values.name); + + // Reset form and close dialog + form.reset(); + onOpenChange(false); + + // Redirect to the newly created search space's onboarding + router.push(`/dashboard/${result.id}/onboard`); + } catch (error) { + console.error("Failed to create search space:", error); + } finally { + setIsSubmitting(false); + } + }; + + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + form.reset(); + } + onOpenChange(newOpen); + }; + + return ( + + + +
+
+ +
+
+ {t("create_title")} + {t("create_description")} +
+
+
+ +
+ + ( + + {t("name_label")} + + + + + + )} + /> + + ( + + + {t("description_label")}{" "} + + ({tCommon("optional")}) + + + + + + + + )} + /> + + + + + + + +
+
+ ); +} + diff --git a/surfsense_web/components/layout/ui/dialogs/index.ts b/surfsense_web/components/layout/ui/dialogs/index.ts new file mode 100644 index 000000000..28f3b387d --- /dev/null +++ b/surfsense_web/components/layout/ui/dialogs/index.ts @@ -0,0 +1,2 @@ +export { CreateSearchSpaceDialog } from "./CreateSearchSpaceDialog"; + diff --git a/surfsense_web/components/layout/ui/index.ts b/surfsense_web/components/layout/ui/index.ts index 31e288561..1c3ddb2ca 100644 --- a/surfsense_web/components/layout/ui/index.ts +++ b/surfsense_web/components/layout/ui/index.ts @@ -1,3 +1,4 @@ +export { CreateSearchSpaceDialog } from "./dialogs"; export { Header } from "./header"; export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail"; export { LayoutShell } from "./shell"; diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 8b0164211..52991793a 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -77,6 +77,16 @@ "creating_account_btn": "Creating account...", "redirecting_login": "Redirecting to login page..." }, + "searchSpace": { + "create_title": "Create Search Space", + "create_description": "Create a new search space to organize your knowledge", + "name_label": "Name", + "name_placeholder": "Enter search space name", + "description_label": "Description", + "description_placeholder": "What is this search space for?", + "create_button": "Create", + "creating": "Creating..." + }, "dashboard": { "title": "Dashboard", "search_spaces": "Search Spaces", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index ee04baad5..ea3af36e3 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -77,6 +77,16 @@ "creating_account_btn": "创建中...", "redirecting_login": "正在跳转到登录页面..." }, + "searchSpace": { + "create_title": "创建搜索空间", + "create_description": "创建一个新的搜索空间来组织您的知识", + "name_label": "名称", + "name_placeholder": "输入搜索空间名称", + "description_label": "描述", + "description_placeholder": "这个搜索空间是做什么的?", + "create_button": "创建", + "creating": "创建中..." + }, "dashboard": { "title": "仪表盘", "search_spaces": "搜索空间", From 7a58f2f568425317cd44ff6f4d056859727f7052 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 12 Jan 2026 15:59:03 +0200 Subject: [PATCH 26/49] Add AllSearchSpacesSheet component --- surfsense_web/components/layout/index.ts | 1 + surfsense_web/components/layout/ui/index.ts | 1 + .../layout/ui/sheets/AllSearchSpacesSheet.tsx | 130 ++++++++++++++++++ .../components/layout/ui/sheets/index.ts | 2 + surfsense_web/messages/en.json | 11 +- surfsense_web/messages/zh.json | 11 +- 6 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx create mode 100644 surfsense_web/components/layout/ui/sheets/index.ts diff --git a/surfsense_web/components/layout/index.ts b/surfsense_web/components/layout/index.ts index 3a79ccde7..b9c271915 100644 --- a/surfsense_web/components/layout/index.ts +++ b/surfsense_web/components/layout/index.ts @@ -11,6 +11,7 @@ export type { SearchSpace, } from "./types/layout.types"; export { + AllSearchSpacesSheet, ChatListItem, CreateSearchSpaceDialog, Header, diff --git a/surfsense_web/components/layout/ui/index.ts b/surfsense_web/components/layout/ui/index.ts index 1c3ddb2ca..c5aba9250 100644 --- a/surfsense_web/components/layout/ui/index.ts +++ b/surfsense_web/components/layout/ui/index.ts @@ -2,6 +2,7 @@ export { CreateSearchSpaceDialog } from "./dialogs"; export { Header } from "./header"; export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail"; export { LayoutShell } from "./shell"; +export { AllSearchSpacesSheet } from "./sheets"; export { ChatListItem, MobileSidebar, diff --git a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx b/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx new file mode 100644 index 000000000..29c1b8791 --- /dev/null +++ b/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { Crown, Search, Users } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; +import type { SearchSpace } from "../../types/layout.types"; + +interface AllSearchSpacesSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + searchSpaces: SearchSpace[]; + activeSearchSpaceId: number | null; + onSearchSpaceSelect: (id: number) => void; + onCreateNew?: () => void; +} + +export function AllSearchSpacesSheet({ + open, + onOpenChange, + searchSpaces, + activeSearchSpaceId, + onSearchSpaceSelect, + onCreateNew, +}: AllSearchSpacesSheetProps) { + const t = useTranslations("searchSpace"); + const tCommon = useTranslations("common"); + + const handleSelect = (id: number) => { + onSearchSpaceSelect(id); + onOpenChange(false); + }; + + return ( + + + +
+
+ +
+
+ {t("all_search_spaces")} + + {t("search_spaces_count", { count: searchSpaces.length })} + +
+
+
+ +
+ {searchSpaces.length === 0 ? ( +
+
+ +
+
+

{t("no_search_spaces")}

+

+ {t("create_first_search_space")} +

+
+ {onCreateNew && ( + + )} +
+ ) : ( + searchSpaces.map((space) => ( + + )) + )} +
+ + {searchSpaces.length > 0 && onCreateNew && ( +
+ +
+ )} +
+
+ ); +} + diff --git a/surfsense_web/components/layout/ui/sheets/index.ts b/surfsense_web/components/layout/ui/sheets/index.ts new file mode 100644 index 000000000..b2d05f1a8 --- /dev/null +++ b/surfsense_web/components/layout/ui/sheets/index.ts @@ -0,0 +1,2 @@ +export { AllSearchSpacesSheet } from "./AllSearchSpacesSheet"; + diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 52991793a..2ab400102 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -28,7 +28,8 @@ "info": "Information", "required": "Required", "optional": "Optional", - "retry": "Retry" + "retry": "Retry", + "owner": "Owner" }, "auth": { "login": "Login", @@ -85,7 +86,13 @@ "description_label": "Description", "description_placeholder": "What is this search space for?", "create_button": "Create", - "creating": "Creating..." + "creating": "Creating...", + "all_search_spaces": "All Search Spaces", + "search_spaces_count": "{count, plural, =0 {No search spaces} =1 {1 search space} other {# search spaces}}", + "no_search_spaces": "No search spaces yet", + "create_first_search_space": "Create your first search space to get started", + "members_count": "{count, plural, =1 {1 member} other {# members}}", + "create_new_search_space": "Create new search space" }, "dashboard": { "title": "Dashboard", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index ea3af36e3..bcfeb1ef4 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -28,7 +28,8 @@ "info": "信息", "required": "必填", "optional": "可选", - "retry": "重试" + "retry": "重试", + "owner": "所有者" }, "auth": { "login": "登录", @@ -85,7 +86,13 @@ "description_label": "描述", "description_placeholder": "这个搜索空间是做什么的?", "create_button": "创建", - "creating": "创建中..." + "creating": "创建中...", + "all_search_spaces": "所有搜索空间", + "search_spaces_count": "{count, plural, =0 {没有搜索空间} other {# 个搜索空间}}", + "no_search_spaces": "暂无搜索空间", + "create_first_search_space": "创建您的第一个搜索空间以开始使用", + "members_count": "{count, plural, other {# 位成员}}", + "create_new_search_space": "创建新的搜索空间" }, "dashboard": { "title": "仪表盘", From 38fd1995aa9ff04f05f41b4bd4dd05bed55a00d3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 12 Jan 2026 16:38:40 +0200 Subject: [PATCH 27/49] Refactor dashboard with smart redirect and AllSearchSpacesSheet --- surfsense_web/app/dashboard/page.tsx | 328 ++---------------- .../layout/providers/LayoutDataProvider.tsx | 63 +++- .../components/layout/types/layout.types.ts | 1 + .../layout/ui/sheets/AllSearchSpacesSheet.tsx | 268 +++++++++----- surfsense_web/messages/en.json | 8 +- surfsense_web/messages/zh.json | 8 +- 6 files changed, 288 insertions(+), 388 deletions(-) diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index ad1c6ad9d..aa6709af3 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -1,32 +1,14 @@ "use client"; import { useAtomValue } from "jotai"; -import { AlertCircle, Loader2, Plus, Search, Trash2, UserCheck, Users } from "lucide-react"; -import { motion, type Variants } from "motion/react"; -import Image from "next/image"; -import Link from "next/link"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useEffect } from "react"; -import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; +import { useEffect, useState } from "react"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { Logo } from "@/components/Logo"; -import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; -import { UserDropdown } from "@/components/UserDropdown"; +import { CreateSearchSpaceDialog } from "@/components/layout"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, @@ -36,29 +18,11 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Spotlight } from "@/components/ui/spotlight"; -import { Tilt } from "@/components/ui/tilt"; -/** - * Formats a date string into a readable format - * @param dateString - The date string to format - * @returns Formatted date string (e.g., "Jan 1, 2023") - */ -const formatDate = (dateString: string): string => { - return new Date(dateString).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); -}; - -/** - * Loading screen component with animation - */ -const LoadingScreen = () => { +function LoadingScreen() { const t = useTranslations("dashboard"); return ( -
+
{ @@ -84,23 +48,20 @@ const LoadingScreen = () => {
); -}; +} -/** - * Error screen component with animation - */ -const ErrorScreen = ({ message }: { message: string }) => { +function ErrorScreen({ message }: { message: string }) { const t = useTranslations("dashboard"); const router = useRouter(); return ( -
+
- +
@@ -109,7 +70,7 @@ const ErrorScreen = ({ message }: { message: string }) => { {t("something_wrong")} - + {t("error_details")} {message} @@ -125,269 +86,42 @@ const ErrorScreen = ({ message }: { message: string }) => {
); -}; +} -const DashboardPage = () => { - const t = useTranslations("dashboard"); - const tCommon = useTranslations("common"); +export default function DashboardPage() { const router = useRouter(); - - // Animation variants - const containerVariants: Variants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1, - }, - }, - }; - - const itemVariants: Variants = { - hidden: { y: 20, opacity: 0 }, - visible: { - y: 0, - opacity: 1, - transition: { - type: "spring", - stiffness: 300, - damping: 24, - }, - }, - }; + const [showCreateDialog, setShowCreateDialog] = useState(false); const { data: searchSpaces = [], - isLoading: loading, + isLoading, error, - refetch: refreshSearchSpaces, } = useAtomValue(searchSpacesAtom); - const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom); - const { data: user, isPending: isLoadingUser, error: userError } = useAtomValue(currentUserAtom); - - // Auto-redirect to chat for users with exactly 1 search space useEffect(() => { - if (loading) return; + if (isLoading) return; - if (searchSpaces.length === 1) { + if (searchSpaces.length === 0) { + setShowCreateDialog(true); + } else { router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`); } - }, [loading, searchSpaces, router]); + }, [isLoading, searchSpaces, router]); - // Create user object for UserDropdown - const customUser = { - name: user?.email ? user.email.split("@")[0] : "User", - email: - user?.email || - (isLoadingUser ? "Loading..." : userError ? "Error loading user" : "Unknown User"), - avatar: "/icon-128.svg", // Default avatar + const handleDialogChange = (open: boolean) => { + setShowCreateDialog(open); }; - // Show loading while loading or auto-redirecting (single search space) - if (loading || (searchSpaces.length === 1 && !error)) return ; + if (isLoading) return ; if (error) return ; - const handleDeleteSearchSpace = async (id: number) => { - await deleteSearchSpace({ id }); - refreshSearchSpaces(); - }; + if (searchSpaces.length > 0) { + return ; + } return ( - - -
-
- -
-

{t("surfsense_dashboard")}

-

{t("welcome_message")}

-
-
-
- - -
-
- -
-
-

{t("your_search_spaces")}

- - - - - -
- -
- {searchSpaces && - searchSpaces.length > 0 && - searchSpaces.map((space) => ( - - - -
-
- - {space.name} -
- -
-
- - - - - - - {t("delete_search_space")} - - {t("delete_space_confirm", { name: space.name })} - - - - {tCommon("cancel")} - handleDeleteSearchSpace(space.id)} - className="bg-destructive hover:bg-destructive/90" - > - {tCommon("delete")} - - - - -
-
-
- -
-
-
-

{space.name}

- {!space.is_owner && ( - - {t("shared")} - - )} -
-

- {space.description} -

-
-
- - {t("created")} {formatDate(space.created_at)} - -
- {space.is_owner ? ( - - ) : ( - - )} - {space.member_count} -
-
-
- -
- - - ))} - - {searchSpaces.length === 0 && ( - -
- -
-

{t("no_spaces_found")}

-

- {t("create_first_space")} -

- - - -
- )} - - {searchSpaces.length > 0 && ( - - - -
- - - {t("add_new_search_space")} - -
- -
-
- )} -
-
- - +
+ +
); -}; - -export default DashboardPage; +} diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index b54f2b2fd..27c3a227c 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -8,6 +8,7 @@ import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; import { useCallback, useMemo, useState } from "react"; import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms"; +import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { Button } from "@/components/ui/button"; @@ -26,7 +27,9 @@ import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence"; import { resetUser, trackLogout } from "@/lib/posthog/events"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import type { ChatItem, NavItem, NoteItem, SearchSpace } from "../types/layout.types"; +import { CreateSearchSpaceDialog } from "../ui/dialogs"; import { LayoutShell } from "../ui/shell"; +import { AllSearchSpacesSheet } from "../ui/sheets"; import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar"; import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar"; @@ -53,7 +56,8 @@ export function LayoutDataProvider({ // Atoms const { data: user } = useAtomValue(currentUserAtom); - const { data: searchSpacesData } = useAtomValue(searchSpacesAtom); + const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom); + const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom); const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom); const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom); @@ -110,6 +114,10 @@ export function LayoutDataProvider({ const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false); const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false); + // Search space sheet and dialog state + const [isAllSearchSpacesSheetOpen, setIsAllSearchSpacesSheetOpen] = useState(false); + const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); + // Delete dialogs state const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); @@ -123,7 +131,6 @@ export function LayoutDataProvider({ } | null>(null); const [isDeletingNote, setIsDeletingNote] = useState(false); - // Transform search spaces (API returns array directly, not { items: [...] }) const searchSpaces: SearchSpace[] = useMemo(() => { if (!searchSpacesData || !Array.isArray(searchSpacesData)) return []; return searchSpacesData.map((space) => ({ @@ -132,6 +139,7 @@ export function LayoutDataProvider({ description: space.description, isOwner: space.is_owner, memberCount: space.member_count || 0, + createdAt: space.created_at, })); }, [searchSpacesData]); @@ -204,12 +212,35 @@ export function LayoutDataProvider({ ); const handleAddSearchSpace = useCallback(() => { - router.push("/dashboard/searchspaces"); - }, [router]); + setIsCreateSearchSpaceDialogOpen(true); + }, []); const handleSeeAllSearchSpaces = useCallback(() => { - router.push("/dashboard"); - }, [router]); + setIsAllSearchSpacesSheetOpen(true); + }, []); + + const handleSearchSpaceSettings = useCallback( + (id: number) => { + router.push(`/dashboard/${id}/settings`); + }, + [router] + ); + + const handleDeleteSearchSpace = useCallback( + async (id: number) => { + await deleteSearchSpace({ id }); + refetchSearchSpaces(); + if (Number(searchSpaceId) === id && searchSpaces.length > 1) { + const remaining = searchSpaces.filter((s) => s.id !== id); + if (remaining.length > 0) { + router.push(`/dashboard/${remaining[0].id}/new-chat`); + } + } else if (searchSpaces.length === 1) { + router.push("/dashboard"); + } + }, + [deleteSearchSpace, refetchSearchSpaces, searchSpaceId, searchSpaces, router] + ); const handleNavItemClick = useCallback( (item: NavItem) => { @@ -439,6 +470,26 @@ export function LayoutDataProvider({ onAddNote={handleAddNote} /> + {/* All Search Spaces Sheet */} + { + setIsAllSearchSpacesSheetOpen(false); + setIsCreateSearchSpaceDialogOpen(true); + }} + onSettings={handleSearchSpaceSettings} + onDelete={handleDeleteSearchSpace} + /> + + {/* Create Search Space Dialog */} + + {/* Delete Note Dialog */} diff --git a/surfsense_web/components/layout/types/layout.types.ts b/surfsense_web/components/layout/types/layout.types.ts index 67ac8172e..34598b43e 100644 --- a/surfsense_web/components/layout/types/layout.types.ts +++ b/surfsense_web/components/layout/types/layout.types.ts @@ -6,6 +6,7 @@ export interface SearchSpace { description?: string | null; isOwner: boolean; memberCount: number; + createdAt?: string; } export interface User { diff --git a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx b/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx index 29c1b8791..f91dda83a 100644 --- a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx +++ b/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx @@ -1,9 +1,27 @@ "use client"; -import { Crown, Search, Users } from "lucide-react"; +import { Calendar, MoreHorizontal, Search, Settings, Share2, Trash2, UserCheck, Users } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Sheet, SheetContent, @@ -11,120 +29,208 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet"; -import { cn } from "@/lib/utils"; import type { SearchSpace } from "../../types/layout.types"; +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + interface AllSearchSpacesSheetProps { open: boolean; onOpenChange: (open: boolean) => void; searchSpaces: SearchSpace[]; - activeSearchSpaceId: number | null; onSearchSpaceSelect: (id: number) => void; onCreateNew?: () => void; + onSettings?: (id: number) => void; + onDelete?: (id: number) => void; } export function AllSearchSpacesSheet({ open, onOpenChange, searchSpaces, - activeSearchSpaceId, onSearchSpaceSelect, onCreateNew, + onSettings, + onDelete, }: AllSearchSpacesSheetProps) { const t = useTranslations("searchSpace"); const tCommon = useTranslations("common"); + const [spaceToDelete, setSpaceToDelete] = useState(null); + const handleSelect = (id: number) => { onSearchSpaceSelect(id); onOpenChange(false); }; - return ( - - - -
-
- -
-
- {t("all_search_spaces")} - - {t("search_spaces_count", { count: searchSpaces.length })} - -
-
-
+ const handleSettings = (e: React.MouseEvent, space: SearchSpace) => { + e.stopPropagation(); + onOpenChange(false); + onSettings?.(space.id); + }; -
- {searchSpaces.length === 0 ? ( -
-
- + const handleDeleteClick = (e: React.MouseEvent, space: SearchSpace) => { + e.stopPropagation(); + setSpaceToDelete(space); + }; + + const confirmDelete = () => { + if (spaceToDelete) { + onDelete?.(spaceToDelete.id); + setSpaceToDelete(null); + } + }; + + return ( + <> + + + +
+
+
-
-

{t("no_search_spaces")}

-

- {t("create_first_search_space")} -

+
+ {t("all_search_spaces")} + + {t("search_spaces_count", { count: searchSpaces.length })} +
- {onCreateNew && ( - - )}
- ) : ( - searchSpaces.map((space) => ( - )} - > -
-
- - {space.name} +
+ ) : ( + searchSpaces.map((space) => ( + + + + handleSettings(e, space)}> + + {tCommon("settings")} + + + handleDeleteClick(e, space)} + className="text-destructive focus:text-destructive" + > + + {tCommon("delete")} + + + + )} +
+
+ +
+ + {space.isOwner ? ( + + ) : ( + + )} + {t("members_count", { count: space.memberCount })} - {space.description && ( - - {space.description} + {space.createdAt && ( + + + {formatDate(space.createdAt)} )}
- {space.isOwner && ( - - - {tCommon("owner")} - - )} -
-
- - - {t("members_count", { count: space.memberCount })} - -
- - )) - )} -
- - {searchSpaces.length > 0 && onCreateNew && ( -
- + + )) + )}
- )} - - + + {searchSpaces.length > 0 && onCreateNew && ( +
+ +
+ )} + + + + !open && setSpaceToDelete(null)}> + + + {t("delete_title")} + + {t("delete_confirm", { name: spaceToDelete?.name })} + + + + {tCommon("cancel")} + + {tCommon("delete")} + + + + + ); } - diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 2ab400102..65ff75978 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -29,7 +29,9 @@ "required": "Required", "optional": "Optional", "retry": "Retry", - "owner": "Owner" + "owner": "Owner", + "shared": "Shared", + "settings": "Settings" }, "auth": { "login": "Login", @@ -92,7 +94,9 @@ "no_search_spaces": "No search spaces yet", "create_first_search_space": "Create your first search space to get started", "members_count": "{count, plural, =1 {1 member} other {# members}}", - "create_new_search_space": "Create new search space" + "create_new_search_space": "Create new search space", + "delete_title": "Delete Search Space", + "delete_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone and will permanently remove all data." }, "dashboard": { "title": "Dashboard", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index bcfeb1ef4..e6a61528b 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -29,7 +29,9 @@ "required": "必填", "optional": "可选", "retry": "重试", - "owner": "所有者" + "owner": "所有者", + "shared": "共享", + "settings": "设置" }, "auth": { "login": "登录", @@ -92,7 +94,9 @@ "no_search_spaces": "暂无搜索空间", "create_first_search_space": "创建您的第一个搜索空间以开始使用", "members_count": "{count, plural, other {# 位成员}}", - "create_new_search_space": "创建新的搜索空间" + "create_new_search_space": "创建新的搜索空间", + "delete_title": "删除搜索空间", + "delete_confirm": "您确定要删除「{name}」吗?此操作无法撤销,将永久删除所有数据。" }, "dashboard": { "title": "仪表盘", From b6ddc233dbc18c8540b6cc416a1ad46b3ba9e42f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 12 Jan 2026 16:45:04 +0200 Subject: [PATCH 28/49] Use hard redirect after search space creation --- .../layout/ui/dialogs/CreateSearchSpaceDialog.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx b/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx index 94646d739..978d46f6c 100644 --- a/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx +++ b/surfsense_web/components/layout/ui/dialogs/CreateSearchSpaceDialog.tsx @@ -3,7 +3,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useAtomValue } from "jotai"; import { Loader2, Plus, Search } from "lucide-react"; -import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -44,7 +43,6 @@ interface CreateSearchSpaceDialogProps { export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpaceDialogProps) { const t = useTranslations("searchSpace"); const tCommon = useTranslations("common"); - const router = useRouter(); const [isSubmitting, setIsSubmitting] = useState(false); const { mutateAsync: createSearchSpace } = useAtomValue(createSearchSpaceMutationAtom); @@ -65,18 +63,12 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac description: values.description || "", }); - // Track search space creation trackSearchSpaceCreated(result.id, values.name); - // Reset form and close dialog - form.reset(); - onOpenChange(false); - - // Redirect to the newly created search space's onboarding - router.push(`/dashboard/${result.id}/onboard`); + // Hard redirect to ensure fresh state + window.location.href = `/dashboard/${result.id}/onboard`; } catch (error) { console.error("Failed to create search space:", error); - } finally { setIsSubmitting(false); } }; From 4a2f62be1f0a71bbc724eb53a91d8d4a76643bd9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 12 Jan 2026 16:47:15 +0200 Subject: [PATCH 29/49] Fix linter errors in LayoutDataProvider and AllSearchSpacesSheet --- .../layout/providers/LayoutDataProvider.tsx | 15 +++++---------- .../layout/ui/sheets/AllSearchSpacesSheet.tsx | 2 +- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 27c3a227c..cdf354c48 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -143,16 +143,11 @@ export function LayoutDataProvider({ })); }, [searchSpacesData]); - // Use searchSpace query result for active search space (more reliable than finding in list) - const activeSearchSpace: SearchSpace | null = searchSpace - ? { - id: searchSpace.id, - name: searchSpace.name, - description: searchSpace.description, - isOwner: searchSpace.is_owner, - memberCount: searchSpace.member_count || 0, - } - : null; + // Find active search space from list (has is_owner and member_count) + const activeSearchSpace: SearchSpace | null = useMemo(() => { + if (!searchSpaceId || !searchSpaces.length) return null; + return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null; + }, [searchSpaceId, searchSpaces]); // Transform chats const chats: ChatItem[] = useMemo(() => { diff --git a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx b/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx index f91dda83a..d144c79b3 100644 --- a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx +++ b/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx @@ -217,7 +217,7 @@ export function AllSearchSpacesSheet({ {t("delete_title")} - {t("delete_confirm", { name: spaceToDelete?.name })} + {t("delete_confirm", { name: spaceToDelete?.name ?? "" })} From 0d4d227c26487ea40c9a0e03373af6e336ce6d9d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 12 Jan 2026 16:55:05 +0200 Subject: [PATCH 30/49] Replace aggressive dialog with friendly empty state for new users --- surfsense_web/app/dashboard/page.tsx | 48 +++++++++++++++++++++------- surfsense_web/messages/en.json | 5 ++- surfsense_web/messages/zh.json | 5 ++- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index aa6709af3..3e6d71829 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useAtomValue } from "jotai"; -import { AlertCircle, Loader2 } from "lucide-react"; +import { AlertCircle, Loader2, Plus, Search } from "lucide-react"; import { motion } from "motion/react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -88,6 +88,37 @@ function ErrorScreen({ message }: { message: string }) { ); } +function EmptyState({ onCreateClick }: { onCreateClick: () => void }) { + const t = useTranslations("searchSpace"); + + return ( +
+ +
+ +
+ +
+

{t("welcome_title")}

+

+ {t("welcome_description")} +

+
+ + +
+
+ ); +} + export default function DashboardPage() { const router = useRouter(); const [showCreateDialog, setShowCreateDialog] = useState(false); @@ -101,17 +132,11 @@ export default function DashboardPage() { useEffect(() => { if (isLoading) return; - if (searchSpaces.length === 0) { - setShowCreateDialog(true); - } else { + if (searchSpaces.length > 0) { router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`); } }, [isLoading, searchSpaces, router]); - const handleDialogChange = (open: boolean) => { - setShowCreateDialog(open); - }; - if (isLoading) return ; if (error) return ; @@ -120,8 +145,9 @@ export default function DashboardPage() { } return ( -
- -
+ <> + setShowCreateDialog(true)} /> + + ); } diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 65ff75978..b3d816925 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -96,7 +96,10 @@ "members_count": "{count, plural, =1 {1 member} other {# members}}", "create_new_search_space": "Create new search space", "delete_title": "Delete Search Space", - "delete_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone and will permanently remove all data." + "delete_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone and will permanently remove all data.", + "welcome_title": "Welcome to SurfSense", + "welcome_description": "Create your first search space to start organizing your knowledge, connecting sources, and chatting with AI.", + "create_first_button": "Create your first search space" }, "dashboard": { "title": "Dashboard", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index e6a61528b..678aa174a 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -96,7 +96,10 @@ "members_count": "{count, plural, other {# 位成员}}", "create_new_search_space": "创建新的搜索空间", "delete_title": "删除搜索空间", - "delete_confirm": "您确定要删除「{name}」吗?此操作无法撤销,将永久删除所有数据。" + "delete_confirm": "您确定要删除「{name}」吗?此操作无法撤销,将永久删除所有数据。", + "welcome_title": "欢迎使用 SurfSense", + "welcome_description": "创建您的第一个搜索空间,开始组织知识、连接数据源并与AI对话。", + "create_first_button": "创建第一个搜索空间" }, "dashboard": { "title": "仪表盘", From 34c9d24970f770919bd90337cbebd41aaa170256 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 12 Jan 2026 17:06:05 +0200 Subject: [PATCH 31/49] Add User Settings page with sidebar pattern and API key section --- .../app/dashboard/user/settings/page.tsx | 323 ++++++++++++++++++ .../layout/providers/LayoutDataProvider.tsx | 5 + .../layout/ui/shell/LayoutShell.tsx | 4 + .../components/layout/ui/sidebar/Sidebar.tsx | 4 +- .../layout/ui/sidebar/SidebarUserProfile.tsx | 18 +- surfsense_web/messages/en.json | 19 ++ surfsense_web/messages/zh.json | 19 ++ 7 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 surfsense_web/app/dashboard/user/settings/page.tsx diff --git a/surfsense_web/app/dashboard/user/settings/page.tsx b/surfsense_web/app/dashboard/user/settings/page.tsx new file mode 100644 index 000000000..508ff65b5 --- /dev/null +++ b/surfsense_web/app/dashboard/user/settings/page.tsx @@ -0,0 +1,323 @@ +"use client"; + +import { + ArrowLeft, + Check, + ChevronRight, + Copy, + Key, + type LucideIcon, + Menu, + Shield, + X, +} from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, 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 { cn } from "@/lib/utils"; + +interface SettingsNavItem { + id: string; + label: string; + description: string; + icon: LucideIcon; +} + +function UserSettingsSidebar({ + activeSection, + onSectionChange, + onBackToApp, + isOpen, + onClose, + navItems, +}: { + activeSection: string; + onSectionChange: (section: string) => void; + onBackToApp: () => void; + isOpen: boolean; + onClose: () => void; + navItems: SettingsNavItem[]; +}) { + const t = useTranslations("userSettings"); + + const handleNavClick = (sectionId: string) => { + onSectionChange(sectionId); + onClose(); + }; + + return ( + <> + + {isOpen && ( + + )} + + + + + ); +} + +function ApiKeyContent({ onMenuClick }: { onMenuClick: () => void }) { + 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"}
+								
+
+ + +
+
+ + ); +} + +export default function UserSettingsPage() { + const t = useTranslations("userSettings"); + const router = useRouter(); + const [activeSection, setActiveSection] = useState("api-key"); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + const navItems: SettingsNavItem[] = [ + { + 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 === "api-key" && ( + setIsSidebarOpen(true)} /> + )} + + ); +} diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index cdf354c48..8f42e22aa 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -214,6 +214,10 @@ export function LayoutDataProvider({ setIsAllSearchSpacesSheetOpen(true); }, []); + const handleUserSettings = useCallback(() => { + router.push("/dashboard/user/settings"); + }, [router]); + const handleSearchSpaceSettings = useCallback( (id: number) => { router.push(`/dashboard/${id}/settings`); @@ -396,6 +400,7 @@ export function LayoutDataProvider({ onSettings={handleSettings} onManageMembers={handleManageMembers} onSeeAllSearchSpaces={handleSeeAllSearchSpaces} + onUserSettings={handleUserSettings} onLogout={handleLogout} pageUsage={pageUsage} breadcrumb={breadcrumb} diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 50f963fb9..ee2978113 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -41,6 +41,7 @@ interface LayoutShellProps { onSettings?: () => void; onManageMembers?: () => void; onSeeAllSearchSpaces?: () => void; + onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; breadcrumb?: React.ReactNode; @@ -77,6 +78,7 @@ export function LayoutShell({ onSettings, onManageMembers, onSeeAllSearchSpaces, + onUserSettings, onLogout, pageUsage, breadcrumb, @@ -131,6 +133,7 @@ export function LayoutShell({ onSettings={onSettings} onManageMembers={onManageMembers} onSeeAllSearchSpaces={onSeeAllSearchSpaces} + onUserSettings={onUserSettings} onLogout={onLogout} pageUsage={pageUsage} /> @@ -179,6 +182,7 @@ export function LayoutShell({ onSettings={onSettings} onManageMembers={onManageMembers} onSeeAllSearchSpaces={onSeeAllSearchSpaces} + onUserSettings={onUserSettings} onLogout={onLogout} pageUsage={pageUsage} className="hidden md:flex border-r shrink-0" diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 240121e0d..69dcb7391 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -45,6 +45,7 @@ interface SidebarProps { onSettings?: () => void; onManageMembers?: () => void; onSeeAllSearchSpaces?: () => void; + onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; className?: string; @@ -72,6 +73,7 @@ export function Sidebar({ onSettings, onManageMembers, onSeeAllSearchSpaces, + onUserSettings, onLogout, pageUsage, className, @@ -287,7 +289,7 @@ export function Sidebar({ )} - +
); diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index 29b35b9a9..d3e97c8eb 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronUp, LogOut } from "lucide-react"; +import { ChevronUp, LogOut, Settings } from "lucide-react"; import { useTranslations } from "next-intl"; import { DropdownMenu, @@ -16,6 +16,7 @@ import type { User } from "../../types/layout.types"; interface SidebarUserProfileProps { user: User; + onUserSettings?: () => void; onLogout?: () => void; isCollapsed?: boolean; } @@ -62,6 +63,7 @@ function getInitials(email: string): string { export function SidebarUserProfile({ user, + onUserSettings, onLogout, isCollapsed = false, }: SidebarUserProfileProps) { @@ -117,6 +119,13 @@ export function SidebarUserProfile({ + + + {t("user_settings")} + + + + {t("logout")} @@ -177,6 +186,13 @@ export function SidebarUserProfile({ + + + {t("user_settings")} + + + + {t("logout")} diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index b3d816925..535efca5d 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -101,6 +101,24 @@ "welcome_description": "Create your first search space to start organizing your knowledge, connecting sources, and chatting with AI.", "create_first_button": "Create your first search space" }, + "userSettings": { + "title": "User Settings", + "description": "Manage your account settings and API access", + "back_to_app": "Back to app", + "footer": "User Settings", + "api_key_nav_label": "API Key", + "api_key_nav_description": "Manage your API access token", + "api_key_title": "API Key", + "api_key_description": "Use this key to authenticate API requests", + "api_key_warning_title": "Keep it secret", + "api_key_warning_description": "Your API key grants full access to your account. Never share it publicly or commit it to version control.", + "your_api_key": "Your API Key", + "copied": "Copied!", + "copy": "Copy to clipboard", + "no_api_key": "No API key found", + "usage_title": "How to use", + "usage_description": "Include your API key in the Authorization header:" + }, "dashboard": { "title": "Dashboard", "search_spaces": "Search Spaces", @@ -658,6 +676,7 @@ "see_all_search_spaces": "See all search spaces", "expand_sidebar": "Expand sidebar", "collapse_sidebar": "Collapse sidebar", + "user_settings": "User settings", "logout": "Logout" }, "errors": { diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 678aa174a..f7ee458e4 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -101,6 +101,24 @@ "welcome_description": "创建您的第一个搜索空间,开始组织知识、连接数据源并与AI对话。", "create_first_button": "创建第一个搜索空间" }, + "userSettings": { + "title": "用户设置", + "description": "管理您的账户设置和API访问", + "back_to_app": "返回应用", + "footer": "用户设置", + "api_key_nav_label": "API密钥", + "api_key_nav_description": "管理您的API访问令牌", + "api_key_title": "API密钥", + "api_key_description": "使用此密钥验证API请求", + "api_key_warning_title": "请保密", + "api_key_warning_description": "您的API密钥可以完全访问您的账户。请勿公开分享或提交到版本控制。", + "your_api_key": "您的API密钥", + "copied": "已复制!", + "copy": "复制到剪贴板", + "no_api_key": "未找到API密钥", + "usage_title": "使用方法", + "usage_description": "在Authorization请求头中包含您的API密钥:" + }, "dashboard": { "title": "仪表盘", "search_spaces": "搜索空间", @@ -652,6 +670,7 @@ "see_all_search_spaces": "查看所有搜索空间", "expand_sidebar": "展开侧边栏", "collapse_sidebar": "收起侧边栏", + "user_settings": "用户设置", "logout": "退出登录" }, "errors": { From 814122d087855c1d5f1feadc9d7b1add80ecec10 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 12 Jan 2026 17:09:45 +0200 Subject: [PATCH 32/49] Remove standalone back button from Team page --- .../app/dashboard/[search_space_id]/team/page.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) 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 13124d756..8ffb6c3bf 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -3,7 +3,6 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { - ArrowLeft, Calendar, Check, Clock, @@ -27,7 +26,7 @@ import { Users, } from "lucide-react"; import { motion } from "motion/react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { @@ -144,7 +143,6 @@ const cardVariants = { }; export default function TeamManagementPage() { - const router = useRouter(); const params = useParams(); const searchSpaceId = Number(params.search_space_id); const [activeTab, setActiveTab] = useState("members"); @@ -334,14 +332,6 @@ export default function TeamManagementPage() {
-
From 3dff7487b1f3b710e76ec7a5be1f935d1940dd4f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 12 Jan 2026 17:12:06 +0200 Subject: [PATCH 33/49] Delete old pages, fix duplicate translations, rename to 'Manage Members' --- .../app/dashboard/api-key/api-key-client.tsx | 185 ------------------ .../app/dashboard/api-key/client-wrapper.tsx | 32 --- surfsense_web/app/dashboard/api-key/page.tsx | 7 - .../app/dashboard/searchspaces/page.tsx | 41 ---- surfsense_web/messages/en.json | 6 +- surfsense_web/messages/zh.json | 4 - 6 files changed, 1 insertion(+), 274 deletions(-) delete mode 100644 surfsense_web/app/dashboard/api-key/api-key-client.tsx delete mode 100644 surfsense_web/app/dashboard/api-key/client-wrapper.tsx delete mode 100644 surfsense_web/app/dashboard/api-key/page.tsx delete mode 100644 surfsense_web/app/dashboard/searchspaces/page.tsx diff --git a/surfsense_web/app/dashboard/api-key/api-key-client.tsx b/surfsense_web/app/dashboard/api-key/api-key-client.tsx deleted file mode 100644 index 9163b52d8..000000000 --- a/surfsense_web/app/dashboard/api-key/api-key-client.tsx +++ /dev/null @@ -1,185 +0,0 @@ -"use client"; - -import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react"; -import { ArrowLeft } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useRouter } from "next/navigation"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { useApiKey } from "@/hooks/use-api-key"; - -const fadeIn = { - hidden: { opacity: 0 }, - visible: { opacity: 1, transition: { duration: 0.4 } }, -}; - -const staggerContainer = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1, - }, - }, -}; - -const ApiKeyClient = () => { - const { apiKey, isLoading, copied, copyToClipboard } = useApiKey(); - const router = useRouter(); - return ( -
- - -

API Key

-

- Your API key for authenticating with the SurfSense API. -

-
- - - - - Important - - Your API key grants full access to your account. Never share it publicly or with - unauthorized users. - - - - - - - - Your API Key - Use this key to authenticate your API requests. - - - - {isLoading ? ( - - ) : apiKey ? ( - -
- - {apiKey} - -
- - - - - - -

{copied ? "Copied!" : "Copy to clipboard"}

-
-
-
-
- ) : ( - - No API key found. - - )} -
-
-
-
- - -

How to use your API key

- - - - -

Authentication

-

- Include your API key in the Authorization header of your requests: -

- - - Authorization: Bearer {apiKey || "YOUR_API_KEY"} - - -
-
-
-
-
-
-
- -
-
- ); -}; - -export default ApiKeyClient; diff --git a/surfsense_web/app/dashboard/api-key/client-wrapper.tsx b/surfsense_web/app/dashboard/api-key/client-wrapper.tsx deleted file mode 100644 index 4397005ef..000000000 --- a/surfsense_web/app/dashboard/api-key/client-wrapper.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import { useEffect, useState } from "react"; - -// Loading component with animation -const LoadingComponent = () => ( -
-
-

Loading API Key Management...

-
-); - -// Dynamically import the ApiKeyClient component -const ApiKeyClient = dynamic(() => import("./api-key-client"), { - ssr: false, - loading: () => , -}); - -export default function ClientWrapper() { - const [isMounted, setIsMounted] = useState(false); - - useEffect(() => { - setIsMounted(true); - }, []); - - if (!isMounted) { - return ; - } - - return ; -} diff --git a/surfsense_web/app/dashboard/api-key/page.tsx b/surfsense_web/app/dashboard/api-key/page.tsx deleted file mode 100644 index 26e0560de..000000000 --- a/surfsense_web/app/dashboard/api-key/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client"; - -import ClientWrapper from "./client-wrapper"; - -export default function ApiKeyPage() { - return ; -} diff --git a/surfsense_web/app/dashboard/searchspaces/page.tsx b/surfsense_web/app/dashboard/searchspaces/page.tsx deleted file mode 100644 index b40eb5d82..000000000 --- a/surfsense_web/app/dashboard/searchspaces/page.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { useAtomValue } from "jotai"; -import { motion } from "motion/react"; -import { useRouter } from "next/navigation"; -import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; -import { SearchSpaceForm } from "@/components/search-space-form"; -import { trackSearchSpaceCreated } from "@/lib/posthog/events"; - -export default function SearchSpacesPage() { - const router = useRouter(); - const { mutateAsync: createSearchSpace } = useAtomValue(createSearchSpaceMutationAtom); - - const handleCreateSearchSpace = async (data: { name: string; description?: string }) => { - const result = await createSearchSpace({ - name: data.name, - description: data.description || "", - }); - - // Track search space creation - trackSearchSpaceCreated(result.id, data.name); - - // Redirect to the newly created search space's onboarding - router.push(`/dashboard/${result.id}/onboard`); - - return result; - }; - - return ( - -
- -
-
- ); -} diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 535efca5d..57f03a0fb 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -666,13 +666,9 @@ "no_archived_chats": "No archived chats", "error_archiving_chat": "Failed to archive chat", "new_chat": "New chat", - "select_workspace": "Select Workspace", "select_search_space": "Select Search Space", - "invite_members": "Invite members", "manage_members": "Manage members", - "workspace_settings": "Workspace settings", - "search_space_settings": "Search space settings", - "see_all_workspaces": "See all search spaces", + "search_space_settings": "Search Space settings", "see_all_search_spaces": "See all search spaces", "expand_sidebar": "Expand sidebar", "collapse_sidebar": "Collapse sidebar", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index f7ee458e4..89cb7813a 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -660,13 +660,9 @@ "view_all_notes": "查看所有笔记", "add_note": "添加笔记", "new_chat": "新对话", - "select_workspace": "选择工作空间", "select_search_space": "选择搜索空间", - "invite_members": "邀请成员", "manage_members": "管理成员", - "workspace_settings": "工作空间设置", "search_space_settings": "搜索空间设置", - "see_all_workspaces": "查看所有搜索空间", "see_all_search_spaces": "查看所有搜索空间", "expand_sidebar": "展开侧边栏", "collapse_sidebar": "收起侧边栏", From dc79fd97b0e3dfd79745c604d800418e1b925b4a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 12 Jan 2026 17:22:05 +0200 Subject: [PATCH 34/49] Fix MobileSidebar missing onUserSettings prop --- surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 3b58f837f..88b22158f 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -41,6 +41,7 @@ interface MobileSidebarProps { onSettings?: () => void; onManageMembers?: () => void; onSeeAllSearchSpaces?: () => void; + onUserSettings?: () => void; onLogout?: () => void; pageUsage?: PageUsage; } @@ -80,6 +81,7 @@ export function MobileSidebar({ onSettings, onManageMembers, onSeeAllSearchSpaces, + onUserSettings, onLogout, pageUsage, }: MobileSidebarProps) { @@ -143,6 +145,7 @@ export function MobileSidebar({ onSettings={onSettings} onManageMembers={onManageMembers} onSeeAllSearchSpaces={onSeeAllSearchSpaces} + onUserSettings={onUserSettings} onLogout={onLogout} pageUsage={pageUsage} className="w-full border-none" From 06779a1a32065857a505020f498e8e09a42bfafb Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 12 Jan 2026 17:30:15 +0200 Subject: [PATCH 35/49] Unify settings pages styling with main layout --- .../[search_space_id]/settings/page.tsx | 35 +++++++++++-------- .../app/dashboard/user/settings/page.tsx | 31 +++++++++------- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx index d53a6b26c..fde676dff 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx @@ -87,7 +87,8 @@ function SettingsSidebar({
@@ -286,20 +287,24 @@ export default function SettingsPage() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} - className="h-full flex bg-background" + className="fixed inset-0 z-50 flex bg-muted/40" > - setIsSidebarOpen(false)} - /> - setIsSidebarOpen(true)} - /> +
+
+ setIsSidebarOpen(false)} + /> + setIsSidebarOpen(true)} + /> +
+
); } diff --git a/surfsense_web/app/dashboard/user/settings/page.tsx b/surfsense_web/app/dashboard/user/settings/page.tsx index 508ff65b5..5b0ac5fa0 100644 --- a/surfsense_web/app/dashboard/user/settings/page.tsx +++ b/surfsense_web/app/dashboard/user/settings/page.tsx @@ -73,7 +73,8 @@ function UserSettingsSidebar({
); } - diff --git a/surfsense_web/components/layout/ui/dialogs/index.ts b/surfsense_web/components/layout/ui/dialogs/index.ts index 28f3b387d..807a227de 100644 --- a/surfsense_web/components/layout/ui/dialogs/index.ts +++ b/surfsense_web/components/layout/ui/dialogs/index.ts @@ -1,2 +1 @@ export { CreateSearchSpaceDialog } from "./CreateSearchSpaceDialog"; - diff --git a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx index 397076cb6..77f4de899 100644 --- a/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx +++ b/surfsense_web/components/layout/ui/icon-rail/SearchSpaceAvatar.tsx @@ -42,7 +42,12 @@ function getInitials(name: string): string { return name.slice(0, 2).toUpperCase(); } -export function SearchSpaceAvatar({ name, isActive, onClick, size = "md" }: SearchSpaceAvatarProps) { +export function SearchSpaceAvatar({ + name, + isActive, + onClick, + size = "md", +}: SearchSpaceAvatarProps) { const bgColor = stringToColor(name); const initials = getInitials(name); const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm"; diff --git a/surfsense_web/components/layout/ui/index.ts b/surfsense_web/components/layout/ui/index.ts index c5aba9250..bd3d54838 100644 --- a/surfsense_web/components/layout/ui/index.ts +++ b/surfsense_web/components/layout/ui/index.ts @@ -1,8 +1,8 @@ export { CreateSearchSpaceDialog } from "./dialogs"; export { Header } from "./header"; export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail"; -export { LayoutShell } from "./shell"; export { AllSearchSpacesSheet } from "./sheets"; +export { LayoutShell } from "./shell"; export { ChatListItem, MobileSidebar, diff --git a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx b/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx index d144c79b3..401de41c3 100644 --- a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx +++ b/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx @@ -1,6 +1,15 @@ "use client"; -import { Calendar, MoreHorizontal, Search, Settings, Share2, Trash2, UserCheck, Users } from "lucide-react"; +import { + Calendar, + MoreHorizontal, + Search, + Settings, + Share2, + Trash2, + UserCheck, + Users, +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { @@ -112,9 +121,7 @@ export function AllSearchSpacesSheet({

{t("no_search_spaces")}

-

- {t("create_first_search_space")} -

+

{t("create_first_search_space")}

{onCreateNew && ( - - - handleSettings(e, space)}> - - {tCommon("settings")} - - - handleDeleteClick(e, space)} - className="text-destructive focus:text-destructive" - > - - {tCommon("delete")} - - - - )} -
+ {space.isOwner && ( + + + + + + handleSettings(e, space)}> + + {tCommon("settings")} + + + handleDeleteClick(e, space)} + className="text-destructive focus:text-destructive" + > + + {tCommon("delete")} + + + + )} +
diff --git a/surfsense_web/components/layout/ui/sheets/index.ts b/surfsense_web/components/layout/ui/sheets/index.ts index b2d05f1a8..d3db749bb 100644 --- a/surfsense_web/components/layout/ui/sheets/index.ts +++ b/surfsense_web/components/layout/ui/sheets/index.ts @@ -1,2 +1 @@ export { AllSearchSpacesSheet } from "./AllSearchSpacesSheet"; - diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index ee2978113..1bb0a015a 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -10,8 +10,8 @@ import type { NavItem, NoteItem, PageUsage, - User, SearchSpace, + User, } from "../../types/layout.types"; import { Header } from "../header"; import { IconRail } from "../icon-rail"; diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 88b22158f..c1874bfd1 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -9,8 +9,8 @@ import type { NavItem, NoteItem, PageUsage, - User, SearchSpace, + User, } from "../../types/layout.types"; import { IconRail } from "../icon-rail"; import { Sidebar } from "./Sidebar"; diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 69dcb7391..0fdec2a03 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -11,8 +11,8 @@ import type { NavItem, NoteItem, PageUsage, - User, SearchSpace, + User, } from "../../types/layout.types"; import { ChatListItem } from "./ChatListItem"; import { NavSection } from "./NavSection"; @@ -289,7 +289,12 @@ export function Sidebar({ )} - +
); diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx index 9373a6169..4ed5e9d34 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx @@ -43,7 +43,9 @@ export function SidebarHeader({ isCollapsed ? "w-10" : "w-50" )} > - {searchSpace?.name ?? t("select_search_space")} + + {searchSpace?.name ?? t("select_search_space")} + diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index d7c281ac6..5849003e2 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -161,52 +161,52 @@ class BaseApiService { } } - // biome-ignore lint/suspicious: Unknown - let data; - const responseType = mergedOptions.responseType; + // biome-ignore lint/suspicious: Unknown + let data; + const responseType = mergedOptions.responseType; - try { - switch (responseType) { - case ResponseType.JSON: - data = await response.json(); - break; - case ResponseType.TEXT: - data = await response.text(); - break; - case ResponseType.BLOB: - data = await response.blob(); - break; - case ResponseType.ARRAY_BUFFER: - data = await response.arrayBuffer(); - break; - // Add more cases as needed - default: - data = await response.json(); + try { + switch (responseType) { + case ResponseType.JSON: + data = await response.json(); + break; + case ResponseType.TEXT: + data = await response.text(); + break; + case ResponseType.BLOB: + data = await response.blob(); + break; + case ResponseType.ARRAY_BUFFER: + data = await response.arrayBuffer(); + break; + // Add more cases as needed + default: + data = await response.json(); + } + } catch (error) { + console.error("Failed to parse response as JSON:", error); + throw new AppError("Failed to parse response", response.status, response.statusText); } - } catch (error) { - console.error("Failed to parse response as JSON:", error); - throw new AppError("Failed to parse response", response.status, response.statusText); - } - // Validate response - if (responseType === ResponseType.JSON) { - if (!responseSchema) { + // Validate response + if (responseType === ResponseType.JSON) { + if (!responseSchema) { + return data; + } + const parsedData = responseSchema.safeParse(data); + + if (!parsedData.success) { + /** The request was successful, but the response data does not match the expected schema. + * This is a client side error, and should be fixed by updating the responseSchema to keep things typed. + * This error should not be shown to the user , it is for dev only. + */ + console.error(`Invalid API response schema - ${url} :`, JSON.stringify(parsedData.error)); + } + return data; } - const parsedData = responseSchema.safeParse(data); - - if (!parsedData.success) { - /** The request was successful, but the response data does not match the expected schema. - * This is a client side error, and should be fixed by updating the responseSchema to keep things typed. - * This error should not be shown to the user , it is for dev only. - */ - console.error(`Invalid API response schema - ${url} :`, JSON.stringify(parsedData.error)); - } return data; - } - - return data; } catch (error) { console.error("Request failed:", JSON.stringify(error)); throw error;