From b440610e0419089a572259749982697135fdc0f9 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Sat, 18 Apr 2026 14:35:14 -0700 Subject: [PATCH 1/8] feat: implement analytics tracking for desktop app events - Added event tracking for desktop app activation and quitting. - Introduced analytics bridge in preload script to handle user identification and event capturing. - Updated IPC channels to support analytics-related actions. - Enhanced analytics functionality in the main process to track user interactions and application updates. - Integrated analytics tracking for folder watching and deep link handling. - Improved connector setup tracking in the web application. This commit enhances the overall analytics capabilities of the application, ensuring better user behavior insights and event tracking across both desktop and web environments. --- surfsense_desktop/src/ipc/channels.ts | 5 + surfsense_desktop/src/ipc/handlers.ts | 42 +++++ surfsense_desktop/src/main.ts | 2 + surfsense_desktop/src/modules/analytics.ts | 102 ++++++++++- surfsense_desktop/src/modules/auto-updater.ts | 15 ++ surfsense_desktop/src/modules/deep-links.ts | 5 + .../src/modules/folder-watcher.ts | 18 ++ surfsense_desktop/src/modules/tray.ts | 29 +-- surfsense_desktop/src/preload.ts | 10 ++ .../components/PurchaseHistoryContent.tsx | 133 +++++++++++--- .../constants/connector-constants.ts | 80 +++++++++ .../hooks/use-connector-dialog.ts | 49 +++++- .../components/free-chat/anonymous-chat.tsx | 7 + .../components/free-chat/free-chat-page.tsx | 9 + surfsense_web/contracts/types/stripe.types.ts | 5 +- surfsense_web/instrumentation-client.ts | 66 ++++++- surfsense_web/lib/posthog/events.ts | 166 ++++++++++++++---- surfsense_web/types/window.d.ts | 10 ++ 18 files changed, 673 insertions(+), 80 deletions(-) diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 61213eb46..410b924b9 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -43,4 +43,9 @@ export const IPC_CHANNELS = { // Active search space GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active', SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active', + // Analytics (PostHog) bridge: renderer <-> main + ANALYTICS_IDENTIFY: 'analytics:identify', + ANALYTICS_RESET: 'analytics:reset', + ANALYTICS_CAPTURE: 'analytics:capture', + ANALYTICS_GET_CONTEXT: 'analytics:get-context', } as const; diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index afb2ba038..63cce3d01 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -28,6 +28,13 @@ import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/activ import { reregisterQuickAsk } from '../modules/quick-ask'; import { reregisterAutocomplete } from '../modules/autocomplete'; import { reregisterGeneralAssist } from '../modules/tray'; +import { + getDistinctId, + getMachineId, + identifyUser as analyticsIdentify, + resetUser as analyticsReset, + trackEvent, +} from '../modules/analytics'; let authTokens: { bearer: string; refresh: string } | null = null; @@ -131,6 +138,41 @@ export function registerIpcHandlers(): void { if (config.generalAssist) await reregisterGeneralAssist(); if (config.quickAsk) await reregisterQuickAsk(); if (config.autocomplete) await reregisterAutocomplete(); + trackEvent('desktop_shortcut_updated', { + keys: Object.keys(config), + }); return updated; }); + + // Analytics bridge — the renderer (web UI) hands the logged-in user down + // to the main process so desktop-only events are attributed to the same + // PostHog person, not just an anonymous machine ID. + ipcMain.handle( + IPC_CHANNELS.ANALYTICS_IDENTIFY, + (_event, payload: { userId: string; properties?: Record }) => { + if (!payload?.userId) return; + analyticsIdentify(String(payload.userId), payload.properties); + } + ); + + ipcMain.handle(IPC_CHANNELS.ANALYTICS_RESET, () => { + analyticsReset(); + }); + + ipcMain.handle( + IPC_CHANNELS.ANALYTICS_CAPTURE, + (_event, payload: { event: string; properties?: Record }) => { + if (!payload?.event) return; + trackEvent(payload.event, payload.properties); + } + ); + + ipcMain.handle(IPC_CHANNELS.ANALYTICS_GET_CONTEXT, () => { + return { + distinctId: getDistinctId(), + machineId: getMachineId(), + appVersion: app.getVersion(), + platform: process.platform, + }; + }); } diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 231553f9a..7556a2743 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -55,6 +55,7 @@ app.whenReady().then(async () => { app.on('activate', () => { const mw = getMainWindow(); + trackEvent('desktop_app_activated'); if (!mw || mw.isDestroyed()) { createMainWindow('/dashboard'); } else { @@ -71,6 +72,7 @@ app.on('window-all-closed', () => { app.on('before-quit', () => { isQuitting = true; + trackEvent('desktop_app_quit'); }); let didCleanup = false; diff --git a/surfsense_desktop/src/modules/analytics.ts b/surfsense_desktop/src/modules/analytics.ts index 0bbcb3026..01dba60f0 100644 --- a/surfsense_desktop/src/modules/analytics.ts +++ b/surfsense_desktop/src/modules/analytics.ts @@ -3,14 +3,27 @@ import { machineIdSync } from 'node-machine-id'; import { app } from 'electron'; let client: PostHog | null = null; -let distinctId = ''; +let machineId = ''; +let currentDistinctId = ''; +let identifiedUserId: string | null = null; + +function baseProperties(): Record { + return { + platform: 'desktop', + app_version: app.getVersion(), + os: process.platform, + arch: process.arch, + machine_id: machineId, + }; +} export function initAnalytics(): void { const key = process.env.POSTHOG_KEY; if (!key) return; try { - distinctId = machineIdSync(true); + machineId = machineIdSync(true); + currentDistinctId = machineId; } catch { return; } @@ -22,17 +35,92 @@ export function initAnalytics(): void { }); } -export function trackEvent(event: string, properties?: Record): void { +export function getMachineId(): string { + return machineId; +} + +export function getDistinctId(): string { + return currentDistinctId; +} + +/** + * Identify the current logged-in user in PostHog so main-process desktop + * events (and linked anonymous machine events) are attributed to that person. + * + * Idempotent: calling identify repeatedly with the same userId is a no-op. + */ +export function identifyUser( + userId: string, + properties?: Record +): void { + if (!client || !userId) return; + if (identifiedUserId === userId) { + // Already identified — only refresh person properties + try { + client.identify({ + distinctId: userId, + properties: { + ...baseProperties(), + $set: { + ...(properties || {}), + platform: 'desktop', + last_seen_at: new Date().toISOString(), + }, + }, + }); + } catch { + // ignore + } + return; + } + + try { + // Link the anonymous machine distinct ID to the authenticated user + client.identify({ + distinctId: userId, + properties: { + ...baseProperties(), + $anon_distinct_id: machineId, + $set: { + ...(properties || {}), + platform: 'desktop', + last_seen_at: new Date().toISOString(), + }, + $set_once: { + first_seen_platform: 'desktop', + }, + }, + }); + + identifiedUserId = userId; + currentDistinctId = userId; + } catch { + // Analytics must never break the app + } +} + +/** + * Reset user identity on logout. Subsequent events are captured anonymously + * against the machine ID until the user logs in again. + */ +export function resetUser(): void { + if (!client) return; + identifiedUserId = null; + currentDistinctId = machineId; +} + +export function trackEvent( + event: string, + properties?: Record +): void { if (!client) return; try { client.capture({ - distinctId, + distinctId: currentDistinctId || machineId, event, properties: { - platform: 'desktop', - app_version: app.getVersion(), - os: process.platform, + ...baseProperties(), ...properties, }, }); diff --git a/surfsense_desktop/src/modules/auto-updater.ts b/surfsense_desktop/src/modules/auto-updater.ts index 47a85b730..e323abe53 100644 --- a/surfsense_desktop/src/modules/auto-updater.ts +++ b/surfsense_desktop/src/modules/auto-updater.ts @@ -1,4 +1,5 @@ import { app, dialog } from 'electron'; +import { trackEvent } from './analytics'; const SEMVER_RE = /^\d+\.\d+\.\d+/; @@ -17,10 +18,18 @@ export function setupAutoUpdater(): void { autoUpdater.on('update-available', (info: { version: string }) => { console.log(`Update available: ${info.version}`); + trackEvent('desktop_update_available', { + current_version: version, + new_version: info.version, + }); }); autoUpdater.on('update-downloaded', (info: { version: string }) => { console.log(`Update downloaded: ${info.version}`); + trackEvent('desktop_update_downloaded', { + current_version: version, + new_version: info.version, + }); dialog.showMessageBox({ type: 'info', buttons: ['Restart', 'Later'], @@ -29,13 +38,19 @@ export function setupAutoUpdater(): void { message: `Version ${info.version} has been downloaded. Restart to apply the update.`, }).then(({ response }: { response: number }) => { if (response === 0) { + trackEvent('desktop_update_install_accepted', { new_version: info.version }); autoUpdater.quitAndInstall(); + } else { + trackEvent('desktop_update_install_deferred', { new_version: info.version }); } }); }); autoUpdater.on('error', (err: Error) => { console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]); + trackEvent('desktop_update_error', { + message: err.message?.split('\n')[0], + }); }); autoUpdater.checkForUpdates().catch(() => {}); diff --git a/surfsense_desktop/src/modules/deep-links.ts b/surfsense_desktop/src/modules/deep-links.ts index 1a2b08395..bfd35bbaf 100644 --- a/surfsense_desktop/src/modules/deep-links.ts +++ b/surfsense_desktop/src/modules/deep-links.ts @@ -2,6 +2,7 @@ import { app } from 'electron'; import path from 'path'; import { getMainWindow } from './window'; import { getServerPort } from './server'; +import { trackEvent } from './analytics'; const PROTOCOL = 'surfsense'; @@ -16,6 +17,10 @@ function handleDeepLink(url: string) { if (!win) return; const parsed = new URL(url); + trackEvent('desktop_deep_link_received', { + host: parsed.hostname, + path: parsed.pathname, + }); if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { const params = parsed.searchParams.toString(); win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); diff --git a/surfsense_desktop/src/modules/folder-watcher.ts b/surfsense_desktop/src/modules/folder-watcher.ts index 96b490d7b..ee4214d8a 100644 --- a/surfsense_desktop/src/modules/folder-watcher.ts +++ b/surfsense_desktop/src/modules/folder-watcher.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'crypto'; import * as path from 'path'; import * as fs from 'fs'; import { IPC_CHANNELS } from '../ipc/channels'; +import { trackEvent } from './analytics'; export interface WatchedFolderConfig { path: string; @@ -401,6 +402,15 @@ export async function addWatchedFolder( await startWatcher(config); } + trackEvent('desktop_folder_watch_added', { + search_space_id: config.searchSpaceId, + root_folder_id: config.rootFolderId, + active: config.active, + has_exclude_patterns: (config.excludePatterns?.length ?? 0) > 0, + has_extension_filter: !!config.fileExtensions && config.fileExtensions.length > 0, + is_update: existing >= 0, + }); + return folders; } @@ -409,6 +419,7 @@ export async function removeWatchedFolder( ): Promise { const s = await getStore(); const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []); + const removed = folders.find((f: WatchedFolderConfig) => f.path === folderPath); const updated = folders.filter((f: WatchedFolderConfig) => f.path !== folderPath); s.set(STORE_KEY, updated); @@ -418,6 +429,13 @@ export async function removeWatchedFolder( const ms = await getMtimeStore(); ms.delete(folderPath); + if (removed) { + trackEvent('desktop_folder_watch_removed', { + search_space_id: removed.searchSpaceId, + root_folder_id: removed.rootFolderId, + }); + } + return updated; } diff --git a/surfsense_desktop/src/modules/tray.ts b/surfsense_desktop/src/modules/tray.ts index 1749145a1..88444cc54 100644 --- a/surfsense_desktop/src/modules/tray.ts +++ b/surfsense_desktop/src/modules/tray.ts @@ -2,6 +2,7 @@ import { app, globalShortcut, Menu, nativeImage, Tray } from 'electron'; import path from 'path'; import { getMainWindow, createMainWindow } from './window'; import { getShortcuts } from './shortcuts'; +import { trackEvent } from './analytics'; let tray: Tray | null = null; let currentShortcut: string | null = null; @@ -15,14 +16,16 @@ function getTrayIcon(): nativeImage { return img.resize({ width: 16, height: 16 }); } -function showMainWindow(): void { - let win = getMainWindow(); - if (!win || win.isDestroyed()) { - win = createMainWindow('/dashboard'); +function showMainWindow(source: 'tray_click' | 'tray_menu' | 'shortcut' = 'tray_click'): void { + const existing = getMainWindow(); + const reopened = !existing || existing.isDestroyed(); + if (reopened) { + createMainWindow('/dashboard'); } else { - win.show(); - win.focus(); + existing.show(); + existing.focus(); } + trackEvent('desktop_main_window_shown', { source, reopened }); } function registerShortcut(accelerator: string): void { @@ -32,7 +35,7 @@ function registerShortcut(accelerator: string): void { } if (!accelerator) return; try { - const ok = globalShortcut.register(accelerator, showMainWindow); + const ok = globalShortcut.register(accelerator, () => showMainWindow('shortcut')); if (ok) { currentShortcut = accelerator; } else { @@ -50,13 +53,19 @@ export async function createTray(): Promise { tray.setToolTip('SurfSense'); const contextMenu = Menu.buildFromTemplate([ - { label: 'Open SurfSense', click: showMainWindow }, + { label: 'Open SurfSense', click: () => showMainWindow('tray_menu') }, { type: 'separator' }, - { label: 'Quit', click: () => { app.exit(0); } }, + { + label: 'Quit', + click: () => { + trackEvent('desktop_tray_quit_clicked'); + app.exit(0); + }, + }, ]); tray.setContextMenu(contextMenu); - tray.on('double-click', showMainWindow); + tray.on('double-click', () => showMainWindow('tray_click')); const shortcuts = await getShortcuts(); registerShortcut(shortcuts.generalAssist); diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index e3d12c5e6..01248e7d0 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -86,4 +86,14 @@ contextBridge.exposeInMainWorld('electronAPI', { getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE), setActiveSearchSpace: (id: string) => ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id), + + // Analytics bridge — lets posthog-js running inside the Next.js renderer + // mirror identify/reset/capture into the Electron main-process PostHog + // client so desktop-only events are attributed to the logged-in user. + analyticsIdentify: (userId: string, properties?: Record) => + ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_IDENTIFY, { userId, properties }), + analyticsReset: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_RESET), + analyticsCapture: (event: string, properties?: Record) => + ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }), + getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT), }); diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx index 9bc77edff..cb079db70 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx @@ -1,7 +1,8 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { ReceiptText } from "lucide-react"; +import { useQueries } from "@tanstack/react-query"; +import { Coins, FileText, ReceiptText } from "lucide-react"; +import { useMemo } from "react"; import { Badge } from "@/components/ui/badge"; import { Spinner } from "@/components/ui/spinner"; import { @@ -12,10 +13,26 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import type { PagePurchase, PagePurchaseStatus } from "@/contracts/types/stripe.types"; +import type { + PagePurchase, + PagePurchaseStatus, + TokenPurchase, +} from "@/contracts/types/stripe.types"; import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { cn } from "@/lib/utils"; +type PurchaseKind = "pages" | "tokens"; + +type UnifiedPurchase = { + id: string; + kind: PurchaseKind; + created_at: string; + status: PagePurchaseStatus; + granted: number; + amount_total: number | null; + currency: string | null; +}; + const STATUS_STYLES: Record = { completed: { label: "Completed", @@ -31,6 +48,22 @@ const STATUS_STYLES: Record; iconClass: string } +> = { + pages: { + label: "Pages", + icon: FileText, + iconClass: "text-sky-500", + }, + tokens: { + label: "Premium Tokens", + icon: Coins, + iconClass: "text-amber-500", + }, +}; + function formatDate(iso: string): string { return new Date(iso).toLocaleDateString(undefined, { year: "numeric", @@ -39,19 +72,65 @@ function formatDate(iso: string): string { }); } -function formatAmount(purchase: PagePurchase): string { - if (purchase.amount_total == null) return "—"; - const dollars = purchase.amount_total / 100; - const currency = (purchase.currency ?? "usd").toUpperCase(); - return `$${dollars.toFixed(2)} ${currency}`; +function formatAmount(amount: number | null, currency: string | null): string { + if (amount == null) return "—"; + const dollars = amount / 100; + const code = (currency ?? "usd").toUpperCase(); + return `$${dollars.toFixed(2)} ${code}`; +} + +function normalizePagePurchase(p: PagePurchase): UnifiedPurchase { + return { + id: p.id, + kind: "pages", + created_at: p.created_at, + status: p.status, + granted: p.pages_granted, + amount_total: p.amount_total, + currency: p.currency, + }; +} + +function normalizeTokenPurchase(p: TokenPurchase): UnifiedPurchase { + return { + id: p.id, + kind: "tokens", + created_at: p.created_at, + status: p.status, + granted: p.tokens_granted, + amount_total: p.amount_total, + currency: p.currency, + }; } export function PurchaseHistoryContent() { - const { data, isLoading } = useQuery({ - queryKey: ["stripe-purchases"], - queryFn: () => stripeApiService.getPurchases(), + const results = useQueries({ + queries: [ + { + queryKey: ["stripe-purchases"], + queryFn: () => stripeApiService.getPurchases(), + }, + { + queryKey: ["stripe-token-purchases"], + queryFn: () => stripeApiService.getTokenPurchases(), + }, + ], }); + const [pagesQuery, tokensQuery] = results; + const isLoading = pagesQuery.isLoading || tokensQuery.isLoading; + + const purchases = useMemo(() => { + const pagePurchases = pagesQuery.data?.purchases ?? []; + const tokenPurchases = tokensQuery.data?.purchases ?? []; + return [ + ...pagePurchases.map(normalizePagePurchase), + ...tokenPurchases.map(normalizeTokenPurchase), + ].sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + }, [pagesQuery.data, tokensQuery.data]); + if (isLoading) { return (
@@ -60,15 +139,13 @@ export function PurchaseHistoryContent() { ); } - const purchases = data?.purchases ?? []; - if (purchases.length === 0) { return (

No purchases yet

- Your page-pack purchases will appear here after checkout. + Your page and premium token purchases will appear here after checkout.

); @@ -81,25 +158,36 @@ export function PurchaseHistoryContent() { Date - Pages + Type + Granted Amount Status {purchases.map((p) => { - const style = STATUS_STYLES[p.status]; + const statusStyle = STATUS_STYLES[p.status]; + const kind = KIND_META[p.kind]; + const KindIcon = kind.icon; return ( - + {formatDate(p.created_at)} - - {p.pages_granted.toLocaleString()} + +
+ + {kind.label} +
- {formatAmount(p)} + {p.granted.toLocaleString()} + + + {formatAmount(p.amount_total, p.currency)} - {style.label} + + {statusStyle.label} +
); @@ -108,7 +196,8 @@ export function PurchaseHistoryContent() {

- Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}. + Showing your {purchases.length} most recent purchase + {purchases.length !== 1 ? "s" : ""}.

); diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts index da6885ffe..d430e0f6c 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts @@ -340,5 +340,85 @@ export const AUTO_INDEX_DEFAULTS: Record = { export const AUTO_INDEX_CONNECTOR_TYPES = new Set(Object.keys(AUTO_INDEX_DEFAULTS)); +// ============================================================================ +// CONNECTOR TELEMETRY REGISTRY +// ---------------------------------------------------------------------------- +// Single source of truth for "what does this connector_type look like in +// analytics?". Any connector added to the lists above is automatically +// picked up here, so adding a new integration does NOT require touching +// `lib/posthog/events.ts` or per-connector tracking code. +// ============================================================================ + +export type ConnectorTelemetryGroup = + | "oauth" + | "composio" + | "crawler" + | "other" + | "unknown"; + +export interface ConnectorTelemetryMeta { + connector_type: string; + connector_title: string; + connector_group: ConnectorTelemetryGroup; + is_oauth: boolean; +} + +const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap = + (() => { + const map = new Map(); + + for (const c of OAUTH_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "oauth", + is_oauth: true, + }); + } + for (const c of COMPOSIO_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "composio", + is_oauth: true, + }); + } + for (const c of CRAWLERS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "crawler", + is_oauth: false, + }); + } + for (const c of OTHER_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "other", + is_oauth: false, + }); + } + + return map; + })(); + +/** + * Returns telemetry metadata for a connector_type, or a minimal "unknown" + * record so tracking never no-ops for connectors that exist in the backend + * but were forgotten in the UI registry. + */ +export function getConnectorTelemetryMeta(connectorType: string): ConnectorTelemetryMeta { + const hit = CONNECTOR_TELEMETRY_REGISTRY.get(connectorType); + if (hit) return hit; + + return { + connector_type: connectorType, + connector_title: connectorType, + connector_group: "unknown", + is_oauth: false, + }; +} + // Re-export IndexingConfigState from schemas for backward compatibility export type { IndexingConfigState } from "./connector-popup.schemas"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index caa85ba2d..7ac903342 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -24,6 +24,8 @@ import { isSelfHosted } from "@/lib/env-config"; import { trackConnectorConnected, trackConnectorDeleted, + trackConnectorSetupFailure, + trackConnectorSetupStarted, trackIndexWithDateRangeOpened, trackIndexWithDateRangeStarted, trackPeriodicIndexingStarted, @@ -232,10 +234,20 @@ export const useConnectorDialog = () => { if (result.error) { const oauthConnector = result.connector - ? OAUTH_CONNECTORS.find((c) => c.id === result.connector) + ? OAUTH_CONNECTORS.find((c) => c.id === result.connector) || + COMPOSIO_CONNECTORS.find((c) => c.id === result.connector) : null; const name = oauthConnector?.title || "connector"; + if (oauthConnector) { + trackConnectorSetupFailure( + Number(searchSpaceId), + oauthConnector.connectorType, + result.error, + "oauth_callback" + ); + } + if (result.error === "duplicate_account") { toast.error(`This ${name} account is already connected`, { description: "Please use a different account or manage the existing connection.", @@ -348,6 +360,12 @@ export const useConnectorDialog = () => { // Set connecting state immediately to disable button and show spinner setConnectingId(connector.id); + trackConnectorSetupStarted( + Number(searchSpaceId), + connector.connectorType, + "oauth_click" + ); + try { // Check if authEndpoint already has query parameters const separator = connector.authEndpoint.includes("?") ? "&" : "?"; @@ -369,6 +387,12 @@ export const useConnectorDialog = () => { window.location.href = validatedData.auth_url; } catch (error) { console.error(`Error connecting to ${connector.title}:`, error); + trackConnectorSetupFailure( + Number(searchSpaceId), + connector.connectorType, + error instanceof Error ? error.message : "oauth_initiation_failed", + "oauth_init" + ); if (error instanceof Error && error.message.includes("Invalid auth URL")) { toast.error(`Invalid response from ${connector.title} OAuth endpoint`); } else { @@ -392,6 +416,11 @@ export const useConnectorDialog = () => { if (!searchSpaceId) return; setConnectingId("webcrawler-connector"); + trackConnectorSetupStarted( + Number(searchSpaceId), + EnumConnectorName.WEBCRAWLER_CONNECTOR, + "webcrawler_quick_add" + ); try { await createConnector({ data: { @@ -441,6 +470,12 @@ export const useConnectorDialog = () => { } } catch (error) { console.error("Error creating webcrawler connector:", error); + trackConnectorSetupFailure( + Number(searchSpaceId), + EnumConnectorName.WEBCRAWLER_CONNECTOR, + error instanceof Error ? error.message : "webcrawler_create_failed", + "webcrawler_quick_add" + ); toast.error("Failed to create web crawler connector"); } finally { setConnectingId(null); @@ -452,6 +487,12 @@ export const useConnectorDialog = () => { (connectorType: string) => { if (!searchSpaceId) return; + trackConnectorSetupStarted( + Number(searchSpaceId), + connectorType, + "non_oauth_click" + ); + // Handle Obsidian specifically on Desktop & Cloud if (connectorType === EnumConnectorName.OBSIDIAN_CONNECTOR && !selfHosted && isDesktop) { setIsOpen(false); @@ -680,6 +721,12 @@ export const useConnectorDialog = () => { } } catch (error) { console.error("Error creating connector:", error); + trackConnectorSetupFailure( + Number(searchSpaceId), + connectingConnectorType ?? formData.connector_type, + error instanceof Error ? error.message : "connector_create_failed", + "non_oauth_form" + ); toast.error(error instanceof Error ? error.message : "Failed to create connector"); } finally { isCreatingConnectorRef.current = false; diff --git a/surfsense_web/components/free-chat/anonymous-chat.tsx b/surfsense_web/components/free-chat/anonymous-chat.tsx index 1ac6baad4..b286c5316 100644 --- a/surfsense_web/components/free-chat/anonymous-chat.tsx +++ b/surfsense_web/components/free-chat/anonymous-chat.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { readSSEStream } from "@/lib/chat/streaming-state"; +import { trackAnonymousChatMessageSent } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; import { QuotaBar } from "./quota-bar"; import { QuotaWarningBanner } from "./quota-warning-banner"; @@ -61,6 +62,12 @@ export function AnonymousChat({ model }: AnonymousChatProps) { textareaRef.current.style.height = "auto"; } + trackAnonymousChatMessageSent({ + modelSlug: model.seo_slug, + messageLength: trimmed.length, + surface: "free_model_page", + }); + const controller = new AbortController(); abortRef.current = controller; diff --git a/surfsense_web/components/free-chat/free-chat-page.tsx b/surfsense_web/components/free-chat/free-chat-page.tsx index b1d0f6850..b389a8489 100644 --- a/surfsense_web/components/free-chat/free-chat-page.tsx +++ b/surfsense_web/components/free-chat/free-chat-page.tsx @@ -28,6 +28,7 @@ import { updateToolCall, } from "@/lib/chat/streaming-state"; import { BACKEND_URL } from "@/lib/env-config"; +import { trackAnonymousChatMessageSent } from "@/lib/posthog/events"; import { FreeModelSelector } from "./free-model-selector"; import { FreeThread } from "./free-thread"; @@ -206,6 +207,14 @@ export function FreeChatPage() { } if (!userQuery.trim()) return; + trackAnonymousChatMessageSent({ + modelSlug, + messageLength: userQuery.trim().length, + hasUploadedDoc: + anonMode.isAnonymous && anonMode.uploadedDoc !== null ? true : false, + surface: "free_chat_page", + }); + const userMsgId = `msg-user-${Date.now()}`; setMessages((prev) => [ ...prev, diff --git a/surfsense_web/contracts/types/stripe.types.ts b/surfsense_web/contracts/types/stripe.types.ts index c4c6f2d74..c8b017044 100644 --- a/surfsense_web/contracts/types/stripe.types.ts +++ b/surfsense_web/contracts/types/stripe.types.ts @@ -49,6 +49,8 @@ export const tokenStripeStatusResponse = z.object({ premium_tokens_remaining: z.number().default(0), }); +export const tokenPurchaseStatusEnum = pagePurchaseStatusEnum; + export const tokenPurchase = z.object({ id: z.uuid(), stripe_checkout_session_id: z.string(), @@ -57,7 +59,7 @@ export const tokenPurchase = z.object({ tokens_granted: z.number(), amount_total: z.number().nullable(), currency: z.string().nullable(), - status: z.string(), + status: tokenPurchaseStatusEnum, completed_at: z.string().nullable(), created_at: z.string(), }); @@ -75,5 +77,6 @@ export type GetPagePurchasesResponse = z.infer; export type CreateTokenCheckoutSessionRequest = z.infer; export type CreateTokenCheckoutSessionResponse = z.infer; export type TokenStripeStatusResponse = z.infer; +export type TokenPurchaseStatus = z.infer; export type TokenPurchase = z.infer; export type GetTokenPurchasesResponse = z.infer; diff --git a/surfsense_web/instrumentation-client.ts b/surfsense_web/instrumentation-client.ts index dff2e9bfe..3ae97fc0b 100644 --- a/surfsense_web/instrumentation-client.ts +++ b/surfsense_web/instrumentation-client.ts @@ -1,18 +1,65 @@ import posthog from "posthog-js"; -function initPostHog() { +/** + * PostHog initialisation for the Next.js renderer. + * + * The same bundle ships in two contexts: + * 1. A normal browser session on surfsense.com -> platform = "web" + * 2. The Electron desktop app (renders the Next app from localhost) + * -> platform = "desktop" + * + * When running inside Electron we also seed `posthog-js` with the main + * process's machine distinctId so that events fired from both the renderer + * (e.g. `chat_message_sent`, page views) and the Electron main process + * (e.g. `desktop_quick_ask_opened`) share a single PostHog person before + * login, and can be merged into the authenticated user afterwards. + */ + +function isElectron(): boolean { + return typeof window !== "undefined" && !!window.electronAPI; +} + +function currentPlatform(): "desktop" | "web" { + return isElectron() ? "desktop" : "web"; +} + +async function resolveBootstrapDistinctId(): Promise { + if (!isElectron() || !window.electronAPI?.getAnalyticsContext) return undefined; + try { + const ctx = await window.electronAPI.getAnalyticsContext(); + return ctx?.machineId || ctx?.distinctId || undefined; + } catch { + return undefined; + } +} + +async function initPostHog() { try { if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return; + const platform = currentPlatform(); + const bootstrapDistinctId = await resolveBootstrapDistinctId(); + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: "https://assets.surfsense.com", ui_host: "https://us.posthog.com", defaults: "2026-01-30", capture_pageview: "history_change", capture_pageleave: true, + ...(bootstrapDistinctId + ? { + bootstrap: { + distinctID: bootstrapDistinctId, + isIdentifiedID: false, + }, + } + : {}), before_send: (event) => { if (event?.properties) { - event.properties.platform = "web"; + event.properties.platform = platform; + if (platform === "desktop") { + event.properties.is_desktop = true; + } const params = new URLSearchParams(window.location.search); const ref = params.get("ref"); @@ -30,9 +77,14 @@ function initPostHog() { event.properties.$set = { ...event.properties.$set, - platform: "web", + platform, last_seen_at: new Date().toISOString(), }; + + event.properties.$set_once = { + ...event.properties.$set_once, + first_seen_platform: platform, + }; } return event; }, @@ -51,8 +103,12 @@ if (typeof window !== "undefined") { window.posthog = posthog; if ("requestIdleCallback" in window) { - requestIdleCallback(initPostHog); + requestIdleCallback(() => { + void initPostHog(); + }); } else { - setTimeout(initPostHog, 3500); + setTimeout(() => { + void initPostHog(); + }, 3500); } } diff --git a/surfsense_web/lib/posthog/events.ts b/surfsense_web/lib/posthog/events.ts index 53aaa71b9..34ed3044d 100644 --- a/surfsense_web/lib/posthog/events.ts +++ b/surfsense_web/lib/posthog/events.ts @@ -1,4 +1,5 @@ import posthog from "posthog-js"; +import { getConnectorTelemetryMeta } from "@/components/assistant-ui/connector-popup/constants/connector-constants"; /** * PostHog Analytics Event Definitions @@ -13,8 +14,8 @@ import posthog from "posthog-js"; * - auth: Authentication events * - search_space: Search space management * - document: Document management - * - chat: Chat and messaging - * - connector: External connector events + * - chat: Chat and messaging (authenticated + anonymous) + * - connector: External connector events (all lifecycle stages) * - contact: Contact form events * - settings: Settings changes * - marketing: Marketing/referral tracking @@ -28,6 +29,17 @@ function safeCapture(event: string, properties?: Record) { } } +/** + * Drop undefined values so PostHog doesn't log `"foo": undefined` noise. + */ +function compact>(obj: T): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined) out[k] = v; + } + return out; +} + // ============================================ // AUTH EVENTS // ============================================ @@ -127,6 +139,28 @@ export function trackChatError(searchSpaceId: number, chatId: number, error?: st }); } +/** + * Track a message sent from the unauthenticated "free" / anonymous chat + * flow. This is intentionally a separate event from `chat_message_sent` + * so WAU / retention queries on the authenticated event stay clean while + * still giving us visibility into top-of-funnel usage on /free/*. + */ +export function trackAnonymousChatMessageSent(options: { + modelSlug: string; + messageLength?: number; + hasUploadedDoc?: boolean; + webSearchEnabled?: boolean; + surface?: "free_chat_page" | "free_model_page"; +}) { + safeCapture("anonymous_chat_message_sent", { + model_slug: options.modelSlug, + message_length: options.messageLength, + has_uploaded_doc: options.hasUploadedDoc ?? false, + web_search_enabled: options.webSearchEnabled, + surface: options.surface, + }); +} + // ============================================ // DOCUMENT EVENTS // ============================================ @@ -179,37 +213,88 @@ export function trackYouTubeImport(searchSpaceId: number, url: string) { } // ============================================ -// CONNECTOR EVENTS +// CONNECTOR EVENTS (generic lifecycle dispatcher) // ============================================ +// +// All connector events go through `trackConnectorEvent`. The connector's +// human-readable title and its group (oauth/composio/crawler/other) are +// auto-attached from the shared registry in `connector-constants.ts`, so +// adding a new connector to that list is the only change required for it +// to show up correctly in PostHog dashboards. -export function trackConnectorSetupStarted(searchSpaceId: number, connectorType: string) { - safeCapture("connector_setup_started", { - search_space_id: searchSpaceId, - connector_type: connectorType, +export type ConnectorEventStage = + | "setup_started" + | "setup_success" + | "setup_failure" + | "oauth_initiated" + | "connected" + | "deleted" + | "synced"; + +export interface ConnectorEventOptions { + searchSpaceId?: number | null; + connectorId?: number | null; + /** Source of the action (e.g. "oauth_callback", "non_oauth_form", "webcrawler_quick_add"). */ + source?: string; + /** Free-form error message for failure events. */ + error?: string; + /** Extra properties specific to the stage (e.g. frequency_minutes for sync events). */ + extra?: Record; +} + +/** + * Generic connector lifecycle tracker. Every connector analytics event + * should funnel through here so the enrichment stays consistent. + */ +export function trackConnectorEvent( + stage: ConnectorEventStage, + connectorType: string, + options: ConnectorEventOptions = {} +) { + const meta = getConnectorTelemetryMeta(connectorType); + safeCapture(`connector_${stage}`, { + ...compact({ + search_space_id: options.searchSpaceId ?? undefined, + connector_id: options.connectorId ?? undefined, + source: options.source, + error: options.error, + }), + connector_type: meta.connector_type, + connector_title: meta.connector_title, + connector_group: meta.connector_group, + is_oauth: meta.is_oauth, + ...(options.extra ?? {}), }); } +// ---- Convenience wrappers kept for backward compatibility ---- + +export function trackConnectorSetupStarted( + searchSpaceId: number, + connectorType: string, + source?: string +) { + trackConnectorEvent("setup_started", connectorType, { searchSpaceId, source }); +} + export function trackConnectorSetupSuccess( searchSpaceId: number, connectorType: string, connectorId: number ) { - safeCapture("connector_setup_success", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("setup_success", connectorType, { searchSpaceId, connectorId }); } export function trackConnectorSetupFailure( - searchSpaceId: number, + searchSpaceId: number | null | undefined, connectorType: string, - error?: string + error?: string, + source?: string ) { - safeCapture("connector_setup_failure", { - search_space_id: searchSpaceId, - connector_type: connectorType, + trackConnectorEvent("setup_failure", connectorType, { + searchSpaceId: searchSpaceId ?? undefined, error, + source, }); } @@ -218,11 +303,7 @@ export function trackConnectorDeleted( connectorType: string, connectorId: number ) { - safeCapture("connector_deleted", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("deleted", connectorType, { searchSpaceId, connectorId }); } export function trackConnectorSynced( @@ -230,11 +311,7 @@ export function trackConnectorSynced( connectorType: string, connectorId: number ) { - safeCapture("connector_synced", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("synced", connectorType, { searchSpaceId, connectorId }); } // ============================================ @@ -345,10 +422,9 @@ export function trackConnectorConnected( connectorType: string, connectorId?: number ) { - safeCapture("connector_connected", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, + trackConnectorEvent("connected", connectorType, { + searchSpaceId, + connectorId: connectorId ?? undefined, }); } @@ -467,8 +543,13 @@ export function trackReferralLanding(refCode: string, landingUrl: string) { // ============================================ /** - * Identify a user for PostHog analytics - * Call this after successful authentication + * Identify a user for PostHog analytics. + * Call this after successful authentication. + * + * In the Electron desktop app the same call is mirrored into the + * main-process PostHog client so desktop-only events (e.g. + * `desktop_quick_ask_opened`, `desktop_autocomplete_accepted`) are + * attributed to the logged-in user rather than an anonymous machine ID. */ export function identifyUser(userId: string, properties?: Record) { try { @@ -476,10 +557,19 @@ export function identifyUser(userId: string, properties?: Record Promise; setActiveSearchSpace: (id: string) => Promise; + // Analytics bridge (PostHog mirror into the Electron main process) + analyticsIdentify: (userId: string, properties?: Record) => Promise; + analyticsReset: () => Promise; + analyticsCapture: (event: string, properties?: Record) => Promise; + getAnalyticsContext: () => Promise<{ + distinctId: string; + machineId: string; + appVersion: string; + platform: string; + }>; } declare global { From 7a389e7a25ead19dd2bd7c88f3872dd0d07ac8b6 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Sat, 18 Apr 2026 16:05:18 -0700 Subject: [PATCH 2/8] fix: alembic migration nos --- ..._content_type.py => 127_add_report_content_type.py} | 10 +++++----- ...esume_prompt.py => 128_seed_build_resume_prompt.py} | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) rename surfsense_backend/alembic/versions/{126_add_report_content_type.py => 127_add_report_content_type.py} (87%) rename surfsense_backend/alembic/versions/{127_seed_build_resume_prompt.py => 128_seed_build_resume_prompt.py} (89%) diff --git a/surfsense_backend/alembic/versions/126_add_report_content_type.py b/surfsense_backend/alembic/versions/127_add_report_content_type.py similarity index 87% rename from surfsense_backend/alembic/versions/126_add_report_content_type.py rename to surfsense_backend/alembic/versions/127_add_report_content_type.py index 3d9e4860c..93bf471af 100644 --- a/surfsense_backend/alembic/versions/126_add_report_content_type.py +++ b/surfsense_backend/alembic/versions/127_add_report_content_type.py @@ -1,7 +1,7 @@ -"""126_add_report_content_type +"""127_add_report_content_type -Revision ID: 126 -Revises: 125 +Revision ID: 127 +Revises: 126 Create Date: 2026-04-15 Adds content_type column to reports table to distinguish between @@ -16,8 +16,8 @@ import sqlalchemy as sa from alembic import op -revision: str = "126" -down_revision: str | None = "125" +revision: str = "127" +down_revision: str | None = "126" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None diff --git a/surfsense_backend/alembic/versions/127_seed_build_resume_prompt.py b/surfsense_backend/alembic/versions/128_seed_build_resume_prompt.py similarity index 89% rename from surfsense_backend/alembic/versions/127_seed_build_resume_prompt.py rename to surfsense_backend/alembic/versions/128_seed_build_resume_prompt.py index 9e05a0510..886879a7b 100644 --- a/surfsense_backend/alembic/versions/127_seed_build_resume_prompt.py +++ b/surfsense_backend/alembic/versions/128_seed_build_resume_prompt.py @@ -1,7 +1,7 @@ -"""127_seed_build_resume_prompt +"""128_seed_build_resume_prompt -Revision ID: 127 -Revises: 126 +Revision ID: 128 +Revises: 127 Create Date: 2026-04-15 Seeds the 'Build Resume' default prompt for all existing users. @@ -16,8 +16,8 @@ import sqlalchemy as sa from alembic import op -revision: str = "127" -down_revision: str | None = "126" +revision: str = "128" +down_revision: str | None = "127" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None From c2e52fbb48254c573e5d73e6b1eb5c8c7229ac24 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:15:31 -0700 Subject: [PATCH 3/8] refactor(documents-sidebar): convert discarded isExportingKB state to ref Closes #1250 --- .../components/layout/ui/sidebar/DocumentsSidebar.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 103d95c12..daed8747d 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -478,7 +478,7 @@ function AuthenticatedDocumentsSidebar({ setFolderPickerOpen(true); }, []); - const [, setIsExportingKB] = useState(false); + const isExportingKBRef = useRef(false); const [exportWarningOpen, setExportWarningOpen] = useState(false); const [exportWarningContext, setExportWarningContext] = useState<{ folder: FolderDisplay; @@ -508,7 +508,7 @@ function AuthenticatedDocumentsSidebar({ const ctx = exportWarningContext; if (!ctx?.folder) return; - setIsExportingKB(true); + isExportingKBRef.current = true; try { const safeName = ctx.folder.name @@ -524,7 +524,7 @@ function AuthenticatedDocumentsSidebar({ console.error("Folder export failed:", err); toast.error(err instanceof Error ? err.message : "Export failed"); } finally { - setIsExportingKB(false); + isExportingKBRef.current = false; } setExportWarningContext(null); }, [exportWarningContext, searchSpaceId, doExport]); @@ -560,7 +560,7 @@ function AuthenticatedDocumentsSidebar({ return; } - setIsExportingKB(true); + isExportingKBRef.current = true; try { const safeName = folder.name @@ -576,7 +576,7 @@ function AuthenticatedDocumentsSidebar({ console.error("Folder export failed:", err); toast.error(err instanceof Error ? err.message : "Export failed"); } finally { - setIsExportingKB(false); + isExportingKBRef.current = false; } }, [searchSpaceId, getPendingCountInSubtree, doExport] From 2d4adcea6459b65ea163c57b4c670a8279010ea3 Mon Sep 17 00:00:00 2001 From: Aaron Sequeira Date: Sun, 19 Apr 2026 16:09:34 +0530 Subject: [PATCH 4/8] fix(dialogs): move open-reset effects into onOpenChange handlers --- .../documents/CreateFolderDialog.tsx | 20 +++++++++++-------- .../documents/FolderPickerDialog.tsx | 20 +++++++++++-------- .../free-chat/free-model-selector.tsx | 9 +++++---- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/surfsense_web/components/documents/CreateFolderDialog.tsx b/surfsense_web/components/documents/CreateFolderDialog.tsx index 55548146f..5ecfebbe7 100644 --- a/surfsense_web/components/documents/CreateFolderDialog.tsx +++ b/surfsense_web/components/documents/CreateFolderDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -29,12 +29,16 @@ export function CreateFolderDialog({ const [name, setName] = useState(""); const inputRef = useRef(null); - useEffect(() => { - if (open) { - setName(""); - setTimeout(() => inputRef.current?.focus(), 0); - } - }, [open]); + const handleOpenChange = useCallback( + (next: boolean) => { + if (next) { + setName(""); + setTimeout(() => inputRef.current?.focus(), 0); + } + onOpenChange(next); + }, + [onOpenChange] + ); const handleSubmit = useCallback( (e?: React.FormEvent) => { @@ -50,7 +54,7 @@ export function CreateFolderDialog({ const isSubfolder = !!parentFolderName; return ( - +
diff --git a/surfsense_web/components/documents/FolderPickerDialog.tsx b/surfsense_web/components/documents/FolderPickerDialog.tsx index 59e02f726..cb97caa62 100644 --- a/surfsense_web/components/documents/FolderPickerDialog.tsx +++ b/surfsense_web/components/documents/FolderPickerDialog.tsx @@ -1,7 +1,7 @@ "use client"; import { ChevronDown, ChevronRight, Folder, FolderOpen, Home } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -36,12 +36,16 @@ export function FolderPickerDialog({ const [selectedId, setSelectedId] = useState(null); const [expandedIds, setExpandedIds] = useState>(new Set()); - useEffect(() => { - if (open) { - setSelectedId(null); - setExpandedIds(new Set()); - } - }, [open]); + const handleOpenChange = useCallback( + (next: boolean) => { + if (next) { + setSelectedId(null); + setExpandedIds(new Set()); + } + onOpenChange(next); + }, + [onOpenChange] + ); const foldersByParent = useMemo(() => { const map: Record = {}; @@ -123,7 +127,7 @@ export function FolderPickerDialog({ } return ( - +
diff --git a/surfsense_web/components/free-chat/free-model-selector.tsx b/surfsense_web/components/free-chat/free-model-selector.tsx index 40112f780..b25d06db8 100644 --- a/surfsense_web/components/free-chat/free-model-selector.tsx +++ b/surfsense_web/components/free-chat/free-model-selector.tsx @@ -27,13 +27,14 @@ export function FreeModelSelector({ className }: { className?: string }) { anonymousChatApiService.getModels().then(setModels).catch(console.error); }, []); - useEffect(() => { - if (open) { + const handleOpenChange = useCallback((next: boolean) => { + if (next) { setSearchQuery(""); setFocusedIndex(-1); requestAnimationFrame(() => searchInputRef.current?.focus()); } - }, [open]); + setOpen(next); + }, []); const currentModel = useMemo( () => models.find((m) => m.seo_slug === currentSlug) ?? null, @@ -94,7 +95,7 @@ export function FreeModelSelector({ className }: { className?: string }) { ); return ( - +