+ Update Notion Page +
++ {isEditing ? "You can edit the arguments below" : "Requires your approval to proceed"} +
+{interruptData.context.error}
+ ) : ( + <> + {account && ( +New Content
++ {String(args.content)} +
+No content update specified
+ )} +
+ {decided === "approve" || decided === "edit" ? (
+ <>
+
Failed to update Notion page
+{result.message}
+{result.message}
++ {result.message || "Notion page updated successfully"} +
+Updating Notion page...
++ {title} +
++ {description} +
+
+
+## Step 2: Teammate Joins
+
+Your teammate accepts the invite and the Search Space becomes shared between you.
+
+
+
+## Step 3: Make Chat Shared
+
+Toggle any chat to **Shared** so your team can see and participate in it.
+
+
+
+## Step 4: Chat in Realtime
+
+Your team can now chat together in realtime. Messages appear instantly for all participants.
+
+
+
+## Step 5: Add Comments
+
+Tag teammates by adding comments on any message. Great for reviews, feedback, or follow-ups.
+
+
+
+
diff --git a/surfsense_web/content/docs/manual-installation.mdx b/surfsense_web/content/docs/manual-installation.mdx
index b4da781ba..1d30a12ef 100644
--- a/surfsense_web/content/docs/manual-installation.mdx
+++ b/surfsense_web/content/docs/manual-installation.mdx
@@ -278,8 +278,9 @@ In a new terminal window, start the Celery worker to handle background tasks:
# Make sure you're in the surfsense_backend directory
cd surfsense_backend
-# Start Celery worker
-uv run celery -A celery_worker.celery_app worker --loglevel=info --concurrency=1 --pool=solo
+# Start Celery worker (consume both default and connectors queues)
+DEFAULT_Q="${CELERY_TASK_DEFAULT_QUEUE:-surfsense}"
+uv run celery -A celery_worker.celery_app worker --loglevel=info --concurrency=1 --pool=solo --queues="${DEFAULT_Q},${DEFAULT_Q}.connectors"
```
**If using pip/venv:**
@@ -293,8 +294,9 @@ source .venv/bin/activate # Linux/macOS
# OR
.venv\Scripts\activate # Windows
-# Start Celery worker
-celery -A celery_worker.celery_app worker --loglevel=info --concurrency=1 --pool=solo
+# Start Celery worker (consume both default and connectors queues)
+DEFAULT_Q="${CELERY_TASK_DEFAULT_QUEUE:-surfsense}"
+celery -A celery_worker.celery_app worker --loglevel=info --concurrency=1 --pool=solo --queues="${DEFAULT_Q},${DEFAULT_Q}.connectors"
```
**Optional: Start Flower for monitoring Celery tasks:**
diff --git a/surfsense_web/contexts/LocaleContext.tsx b/surfsense_web/contexts/LocaleContext.tsx
index 5b41385e0..405fa4a50 100644
--- a/surfsense_web/contexts/LocaleContext.tsx
+++ b/surfsense_web/contexts/LocaleContext.tsx
@@ -4,8 +4,8 @@ import type React from "react";
import { createContext, useContext, useEffect, useState } from "react";
import enMessages from "../messages/en.json";
import esMessages from "../messages/es.json";
-import ptMessages from "../messages/pt.json";
import hiMessages from "../messages/hi.json";
+import ptMessages from "../messages/pt.json";
import zhMessages from "../messages/zh.json";
type Locale = "en" | "es" | "pt" | "hi" | "zh";
diff --git a/surfsense_web/contracts/types/announcement.types.ts b/surfsense_web/contracts/types/announcement.types.ts
new file mode 100644
index 000000000..3711dae6b
--- /dev/null
+++ b/surfsense_web/contracts/types/announcement.types.ts
@@ -0,0 +1,42 @@
+/**
+ * Announcement system types
+ *
+ * Frontend-only announcement system that supports:
+ * - Multiple announcement categories (update, feature, maintenance, info)
+ * - Important flag for toast notifications
+ * - Read/dismissed state tracking via localStorage
+ */
+
+/** Announcement category */
+export type AnnouncementCategory = "update" | "feature" | "maintenance" | "info";
+
+/** Single announcement entry */
+export interface Announcement {
+ /** Unique identifier */
+ id: string;
+ /** Short title */
+ title: string;
+ /** Full description (supports basic text) */
+ description: string;
+ /** Category for visual styling and filtering */
+ category: AnnouncementCategory;
+ /** ISO date string of when the announcement was published */
+ date: string;
+ /** If true, the user will see a toast notification for this announcement */
+ isImportant: boolean;
+ /** Optional CTA link */
+ link?: {
+ label: string;
+ url: string;
+ };
+}
+
+/** State stored in localStorage for tracking user interactions */
+export interface AnnouncementUserState {
+ /** IDs of announcements the user has read (clicked/viewed) */
+ readIds: string[];
+ /** IDs of important announcements already shown as toasts */
+ toastedIds: string[];
+ /** IDs of announcements the user has explicitly dismissed */
+ dismissedIds: string[];
+}
diff --git a/surfsense_web/contracts/types/connector.types.ts b/surfsense_web/contracts/types/connector.types.ts
index a7760745d..3a11d0399 100644
--- a/surfsense_web/contracts/types/connector.types.ts
+++ b/surfsense_web/contracts/types/connector.types.ts
@@ -133,7 +133,10 @@ export const updateConnectorResponse = searchSourceConnector;
export const deleteConnectorRequest = searchSourceConnector.pick({ id: true });
export const deleteConnectorResponse = z.object({
- message: z.literal("Search source connector deleted successfully"),
+ message: z.string(),
+ status: z.string().optional(),
+ connector_id: z.number().optional(),
+ connector_name: z.string().optional(),
});
/**
diff --git a/surfsense_web/hooks/use-announcements.ts b/surfsense_web/hooks/use-announcements.ts
new file mode 100644
index 000000000..ad1e9b6fe
--- /dev/null
+++ b/surfsense_web/hooks/use-announcements.ts
@@ -0,0 +1,119 @@
+"use client";
+
+import { useCallback, useMemo, useSyncExternalStore } from "react";
+import type { Announcement, AnnouncementCategory } from "@/contracts/types/announcement.types";
+import { announcements } from "@/lib/announcements/announcements-data";
+import {
+ dismissAnnouncement,
+ getAnnouncementState,
+ isAnnouncementDismissed,
+ isAnnouncementRead,
+ markAllAnnouncementsRead,
+ markAnnouncementRead,
+} from "@/lib/announcements/announcements-storage";
+
+// ---------------------------------------------------------------------------
+// External-store plumbing so React re-renders when localStorage changes
+// ---------------------------------------------------------------------------
+
+let stateVersion = 0;
+const listeners = new Set<() => void>();
+
+function subscribe(callback: () => void) {
+ listeners.add(callback);
+ return () => listeners.delete(callback);
+}
+
+function getSnapshot() {
+ return stateVersion;
+}
+
+function getServerSnapshot() {
+ return 0;
+}
+
+/** Bump the version so useSyncExternalStore triggers a re-render */
+function notify() {
+ stateVersion++;
+ for (const listener of listeners) listener();
+}
+
+// ---------------------------------------------------------------------------
+// Enriched announcement with read/dismissed state
+// ---------------------------------------------------------------------------
+
+export interface AnnouncementWithState extends Announcement {
+ isRead: boolean;
+ isDismissed: boolean;
+}
+
+// ---------------------------------------------------------------------------
+// Hook
+// ---------------------------------------------------------------------------
+
+interface UseAnnouncementsOptions {
+ /** Filter by category */
+ category?: AnnouncementCategory;
+ /** If true, include dismissed announcements (default: false) */
+ includeDismissed?: boolean;
+}
+
+export function useAnnouncements(options: UseAnnouncementsOptions = {}) {
+ const { category, includeDismissed = false } = options;
+
+ // Subscribe to state changes (re-renders when localStorage state is bumped)
+ useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+
+ const enriched: AnnouncementWithState[] = useMemo(() => {
+ let items = announcements.map((a) => ({
+ ...a,
+ isRead: isAnnouncementRead(a.id),
+ isDismissed: isAnnouncementDismissed(a.id),
+ }));
+
+ if (category) {
+ items = items.filter((a) => a.category === category);
+ }
+
+ if (!includeDismissed) {
+ items = items.filter((a) => !a.isDismissed);
+ }
+
+ // Sort by date descending (newest first)
+ items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
+
+ return items;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [category, includeDismissed, stateVersion]);
+
+ const unreadCount = useMemo(
+ () => enriched.filter((a) => !a.isRead && !a.isDismissed).length,
+ [enriched]
+ );
+
+ const handleMarkRead = useCallback((id: string) => {
+ markAnnouncementRead(id);
+ notify();
+ }, []);
+
+ const handleMarkAllRead = useCallback(() => {
+ const state = getAnnouncementState();
+ const unreadIds = announcements.filter((a) => !state.readIds.includes(a.id)).map((a) => a.id);
+ markAllAnnouncementsRead(unreadIds);
+ notify();
+ }, []);
+
+ const handleDismiss = useCallback((id: string) => {
+ dismissAnnouncement(id);
+ markAnnouncementRead(id);
+ notify();
+ }, []);
+
+ return {
+ announcements: enriched,
+ unreadCount,
+ markRead: handleMarkRead,
+ markAllRead: handleMarkAllRead,
+ dismiss: handleDismiss,
+ };
+}
diff --git a/surfsense_web/hooks/use-comments-electric.ts b/surfsense_web/hooks/use-comments-electric.ts
index b90f52dce..6ca7748b5 100644
--- a/surfsense_web/hooks/use-comments-electric.ts
+++ b/surfsense_web/hooks/use-comments-electric.ts
@@ -393,11 +393,19 @@ export function useCommentsElectric(threadId: number | null) {
}
if (syncHandleRef.current) {
- syncHandleRef.current.unsubscribe();
+ try {
+ syncHandleRef.current.unsubscribe();
+ } catch {
+ // PGlite may already be closed during cleanup
+ }
syncHandleRef.current = null;
}
if (liveQueryRef.current) {
- liveQueryRef.current.unsubscribe();
+ try {
+ liveQueryRef.current.unsubscribe();
+ } catch {
+ // PGlite may already be closed during cleanup
+ }
liveQueryRef.current = null;
}
};
diff --git a/surfsense_web/hooks/use-connectors-electric.ts b/surfsense_web/hooks/use-connectors-electric.ts
index 08ef0621d..951f1d15a 100644
--- a/surfsense_web/hooks/use-connectors-electric.ts
+++ b/surfsense_web/hooks/use-connectors-electric.ts
@@ -180,11 +180,19 @@ export function useConnectorsElectric(searchSpaceId: number | string | null) {
syncKeyRef.current = null;
if (syncHandleRef.current) {
- syncHandleRef.current.unsubscribe();
+ try {
+ syncHandleRef.current.unsubscribe();
+ } catch {
+ // PGlite may already be closed during cleanup
+ }
syncHandleRef.current = null;
}
if (liveQueryRef.current) {
- liveQueryRef.current.unsubscribe();
+ try {
+ liveQueryRef.current.unsubscribe();
+ } catch {
+ // PGlite may already be closed during cleanup
+ }
liveQueryRef.current = null;
}
};
diff --git a/surfsense_web/hooks/use-documents.ts b/surfsense_web/hooks/use-documents.ts
index 6060b9572..e3a315cd1 100644
--- a/surfsense_web/hooks/use-documents.ts
+++ b/surfsense_web/hooks/use-documents.ts
@@ -230,11 +230,19 @@ export function useDocuments(
async function setupElectricRealtime() {
// Cleanup previous subscriptions
if (syncHandleRef.current) {
- syncHandleRef.current.unsubscribe();
+ try {
+ syncHandleRef.current.unsubscribe();
+ } catch {
+ // PGlite may already be closed during cleanup
+ }
syncHandleRef.current = null;
}
if (liveQueryRef.current) {
- liveQueryRef.current.unsubscribe?.();
+ try {
+ liveQueryRef.current.unsubscribe?.();
+ } catch {
+ // PGlite may already be closed during cleanup
+ }
liveQueryRef.current = null;
}
@@ -420,11 +428,19 @@ export function useDocuments(
return () => {
mounted = false;
if (syncHandleRef.current) {
- syncHandleRef.current.unsubscribe();
+ try {
+ syncHandleRef.current.unsubscribe();
+ } catch {
+ // PGlite may already be closed during cleanup
+ }
syncHandleRef.current = null;
}
if (liveQueryRef.current) {
- liveQueryRef.current.unsubscribe?.();
+ try {
+ liveQueryRef.current.unsubscribe?.();
+ } catch {
+ // PGlite may already be closed during cleanup
+ }
liveQueryRef.current = null;
}
};
diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts
index 56ddb46a4..d30fc607e 100644
--- a/surfsense_web/hooks/use-inbox.ts
+++ b/surfsense_web/hooks/use-inbox.ts
@@ -131,7 +131,11 @@ export function useInbox(
// Clean up previous sync
if (syncHandleRef.current) {
- syncHandleRef.current.unsubscribe();
+ try {
+ syncHandleRef.current.unsubscribe();
+ } catch {
+ // PGlite may already be closed during cleanup
+ }
syncHandleRef.current = null;
}
@@ -174,7 +178,11 @@ export function useInbox(
mounted = false;
userSyncKeyRef.current = null;
if (syncHandleRef.current) {
- syncHandleRef.current.unsubscribe();
+ try {
+ syncHandleRef.current.unsubscribe();
+ } catch {
+ // PGlite may already be closed during cleanup
+ }
syncHandleRef.current = null;
}
};
@@ -199,7 +207,11 @@ export function useInbox(
async function setupLiveQuery() {
// Clean up previous live query
if (liveQueryRef.current) {
- liveQueryRef.current.unsubscribe();
+ try {
+ liveQueryRef.current.unsubscribe();
+ } catch {
+ // PGlite may already be closed during cleanup
+ }
liveQueryRef.current = null;
}
@@ -297,7 +309,11 @@ export function useInbox(
return () => {
mounted = false;
if (liveQueryRef.current) {
- liveQueryRef.current.unsubscribe();
+ try {
+ liveQueryRef.current.unsubscribe();
+ } catch {
+ // PGlite may already be closed during cleanup
+ }
liveQueryRef.current = null;
}
};
diff --git a/surfsense_web/hooks/use-messages-electric.ts b/surfsense_web/hooks/use-messages-electric.ts
index e8c82e92b..728503de9 100644
--- a/surfsense_web/hooks/use-messages-electric.ts
+++ b/surfsense_web/hooks/use-messages-electric.ts
@@ -142,11 +142,19 @@ export function useMessagesElectric(
syncKeyRef.current = null;
if (syncHandleRef.current) {
- syncHandleRef.current.unsubscribe();
+ try {
+ syncHandleRef.current.unsubscribe();
+ } catch {
+ // PGlite may already be closed during cleanup
+ }
syncHandleRef.current = null;
}
if (liveQueryRef.current) {
- liveQueryRef.current.unsubscribe();
+ try {
+ liveQueryRef.current.unsubscribe();
+ } catch {
+ // PGlite may already be closed during cleanup
+ }
liveQueryRef.current = null;
}
};
diff --git a/surfsense_web/lib/announcements/announcements-data.ts b/surfsense_web/lib/announcements/announcements-data.ts
new file mode 100644
index 000000000..f9a22581a
--- /dev/null
+++ b/surfsense_web/lib/announcements/announcements-data.ts
@@ -0,0 +1,64 @@
+import type { Announcement } from "@/contracts/types/announcement.types";
+
+/**
+ * Static announcements data.
+ *
+ * To add a new announcement, append an entry to this array.
+ * Set `isImportant: true` to trigger a toast notification for the user.
+ *
+ * This file can be replaced with an API call in the future.
+ */
+export const announcements: Announcement[] = [
+ {
+ id: "announcement-1",
+ title: "Introducing Announcements",
+ description: "All major announcements will be posted here.",
+ category: "feature",
+ date: "2026-02-17T00:00:00Z",
+ isImportant: true,
+ link: {
+ label: "Check Here",
+ url: "/announcements",
+ },
+ },
+ // {
+ // id: "2026-02-10-podcast-improvements",
+ // title: "Podcast Generation Improvements",
+ // description:
+ // "We've improved podcast generation with faster processing, better audio quality, and support for longer documents. Try it out in any search space.",
+ // category: "update",
+ // date: "2026-02-10T00:00:00Z",
+ // isImportant: false,
+ // },
+ // {
+ // id: "2026-02-08-scheduled-maintenance",
+ // title: "Scheduled Maintenance — Feb 15",
+ // description:
+ // "SurfSense will undergo scheduled maintenance on February 15, 2026 from 2:00 AM to 4:00 AM UTC. During this window, the service may be temporarily unavailable. We apologize for any inconvenience.",
+ // category: "maintenance",
+ // date: "2026-02-08T00:00:00Z",
+ // isImportant: true,
+ // },
+ // {
+ // id: "2026-02-05-new-connectors",
+ // title: "New Connectors Available",
+ // description:
+ // "We've added support for new connectors including Linear, Jira, and Confluence. Connect your project management tools and start chatting with your data.",
+ // category: "feature",
+ // date: "2026-02-05T00:00:00Z",
+ // isImportant: false,
+ // link: {
+ // label: "View connectors",
+ // url: "#connectors",
+ // },
+ // },
+ // {
+ // id: "2026-01-28-team-collaboration",
+ // title: "Enhanced Team Collaboration",
+ // description:
+ // "Shared search spaces now support real-time mentions, comment threads, and role-based access control. Invite your team and collaborate more effectively.",
+ // category: "feature",
+ // date: "2026-01-28T00:00:00Z",
+ // isImportant: false,
+ // },
+];
diff --git a/surfsense_web/lib/announcements/announcements-storage.ts b/surfsense_web/lib/announcements/announcements-storage.ts
new file mode 100644
index 000000000..9b55df6be
--- /dev/null
+++ b/surfsense_web/lib/announcements/announcements-storage.ts
@@ -0,0 +1,107 @@
+import type { AnnouncementUserState } from "@/contracts/types/announcement.types";
+
+const STORAGE_KEY = "surfsense_announcements_state";
+
+const defaultState: AnnouncementUserState = {
+ readIds: [],
+ toastedIds: [],
+ dismissedIds: [],
+};
+
+/**
+ * Get the current announcement user state from localStorage
+ */
+export function getAnnouncementState(): AnnouncementUserState {
+ if (typeof window === "undefined") return defaultState;
+
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return defaultState;
+ const parsed = JSON.parse(raw) as Partial