Merge remote-tracking branch 'upstream/dev' into fix/docker-host-gateway

This commit is contained in:
Anish Sarkar 2026-04-21 11:24:34 +05:30
commit f06e00d77c
28 changed files with 1252 additions and 155 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { BrainCog, Rocket, Zap } from "lucide-react";
import { BrainCog, Power, Rocket, Zap } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder";
@ -30,6 +30,10 @@ export function DesktopContent() {
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState(false);
const [autoLaunchHidden, setAutoLaunchHidden] = useState(true);
const [autoLaunchSupported, setAutoLaunchSupported] = useState(false);
useEffect(() => {
if (!api) {
setLoading(false);
@ -38,19 +42,28 @@ export function DesktopContent() {
}
let mounted = true;
const hasAutoLaunchApi =
typeof api.getAutoLaunch === "function" && typeof api.setAutoLaunch === "function";
setAutoLaunchSupported(hasAutoLaunchApi);
Promise.all([
api.getAutocompleteEnabled(),
api.getShortcuts?.() ?? Promise.resolve(null),
api.getActiveSearchSpace?.() ?? Promise.resolve(null),
searchSpacesApiService.getSearchSpaces(),
hasAutoLaunchApi ? api.getAutoLaunch() : Promise.resolve(null),
])
.then(([autoEnabled, config, spaceId, spaces]) => {
.then(([autoEnabled, config, spaceId, spaces, autoLaunch]) => {
if (!mounted) return;
setEnabled(autoEnabled);
if (config) setShortcuts(config);
setActiveSpaceId(spaceId);
if (spaces) setSearchSpaces(spaces);
if (autoLaunch) {
setAutoLaunchEnabled(autoLaunch.enabled);
setAutoLaunchHidden(autoLaunch.openAsHidden);
setAutoLaunchSupported(autoLaunch.supported);
}
setLoading(false);
setShortcutsLoaded(true);
})
@ -106,6 +119,40 @@ export function DesktopContent() {
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
};
const handleAutoLaunchToggle = async (checked: boolean) => {
if (!autoLaunchSupported || !api.setAutoLaunch) {
toast.error("Please update the desktop app to configure launch on startup");
return;
}
setAutoLaunchEnabled(checked);
try {
const next = await api.setAutoLaunch(checked, autoLaunchHidden);
if (next) {
setAutoLaunchEnabled(next.enabled);
setAutoLaunchHidden(next.openAsHidden);
setAutoLaunchSupported(next.supported);
}
toast.success(checked ? "SurfSense will launch on startup" : "Launch on startup disabled");
} catch {
setAutoLaunchEnabled(!checked);
toast.error("Failed to update launch on startup");
}
};
const handleAutoLaunchHiddenToggle = async (checked: boolean) => {
if (!autoLaunchSupported || !api.setAutoLaunch) {
toast.error("Please update the desktop app to configure startup behavior");
return;
}
setAutoLaunchHidden(checked);
try {
await api.setAutoLaunch(autoLaunchEnabled, checked);
} catch {
setAutoLaunchHidden(!checked);
toast.error("Failed to update startup behavior");
}
};
const handleSearchSpaceChange = (value: string) => {
setActiveSpaceId(value);
api.setActiveSearchSpace?.(value);
@ -145,6 +192,60 @@ export function DesktopContent() {
</CardContent>
</Card>
{/* Launch on Startup */}
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg flex items-center gap-2">
<Power className="h-4 w-4" />
Launch on Startup
</CardTitle>
<CardDescription className="text-xs md:text-sm">
Automatically start SurfSense when you sign in to your computer so global
shortcuts and folder sync are always available.
</CardDescription>
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6 space-y-3">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="auto-launch-toggle" className="text-sm font-medium cursor-pointer">
Open SurfSense at login
</Label>
<p className="text-xs text-muted-foreground">
{autoLaunchSupported
? "Adds SurfSense to your system's login items."
: "Only available in the packaged desktop app."}
</p>
</div>
<Switch
id="auto-launch-toggle"
checked={autoLaunchEnabled}
onCheckedChange={handleAutoLaunchToggle}
disabled={!autoLaunchSupported}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label
htmlFor="auto-launch-hidden-toggle"
className="text-sm font-medium cursor-pointer"
>
Start minimized to tray
</Label>
<p className="text-xs text-muted-foreground">
Skip the main window on boot SurfSense lives in the system tray until you need
it.
</p>
</div>
<Switch
id="auto-launch-hidden-toggle"
checked={autoLaunchHidden}
onCheckedChange={handleAutoLaunchHiddenToggle}
disabled={!autoLaunchSupported || !autoLaunchEnabled}
/>
</div>
</CardContent>
</Card>
{/* Keyboard Shortcuts */}
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">

View file

@ -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<PagePurchaseStatus, { label: string; className: string }> = {
completed: {
label: "Completed",
@ -31,6 +48,22 @@ const STATUS_STYLES: Record<PagePurchaseStatus, { label: string; className: stri
},
};
const KIND_META: Record<
PurchaseKind,
{ label: string; icon: React.ComponentType<{ className?: string }>; 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<UnifiedPurchase[]>(() => {
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 (
<div className="flex items-center justify-center py-12">
@ -60,15 +139,13 @@ export function PurchaseHistoryContent() {
);
}
const purchases = data?.purchases ?? [];
if (purchases.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<ReceiptText className="h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium">No purchases yet</p>
<p className="text-xs text-muted-foreground">
Your page-pack purchases will appear here after checkout.
Your page and premium token purchases will appear here after checkout.
</p>
</div>
);
@ -81,25 +158,36 @@ export function PurchaseHistoryContent() {
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead className="text-right">Pages</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Granted</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead className="text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow key={p.id}>
<TableRow key={`${p.kind}-${p.id}`}>
<TableCell className="text-sm">{formatDate(p.created_at)}</TableCell>
<TableCell className="text-right tabular-nums text-sm">
{p.pages_granted.toLocaleString()}
<TableCell className="text-sm">
<div className="flex items-center gap-2">
<KindIcon className={cn("h-4 w-4", kind.iconClass)} />
<span>{kind.label}</span>
</div>
</TableCell>
<TableCell className="text-right tabular-nums text-sm">
{formatAmount(p)}
{p.granted.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums text-sm">
{formatAmount(p.amount_total, p.currency)}
</TableCell>
<TableCell className="text-center">
<Badge className={cn("text-[10px]", style.className)}>{style.label}</Badge>
<Badge className={cn("text-[10px]", statusStyle.className)}>
{statusStyle.label}
</Badge>
</TableCell>
</TableRow>
);
@ -108,7 +196,8 @@ export function PurchaseHistoryContent() {
</Table>
</div>
<p className="text-center text-xs text-muted-foreground">
Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}.
Showing your {purchases.length} most recent purchase
{purchases.length !== 1 ? "s" : ""}.
</p>
</div>
);

View file

@ -340,5 +340,85 @@ export const AUTO_INDEX_DEFAULTS: Record<string, AutoIndexConfig> = {
export const AUTO_INDEX_CONNECTOR_TYPES = new Set<string>(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<string, ConnectorTelemetryMeta> =
(() => {
const map = new Map<string, ConnectorTelemetryMeta>();
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";

View file

@ -18,6 +18,8 @@ import { authenticatedFetch } from "@/lib/auth-utils";
import {
trackConnectorConnected,
trackConnectorDeleted,
trackConnectorSetupFailure,
trackConnectorSetupStarted,
trackIndexWithDateRangeOpened,
trackIndexWithDateRangeStarted,
trackPeriodicIndexingStarted,
@ -222,10 +224,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.",
@ -338,6 +350,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("?") ? "&" : "?";
@ -359,6 +377,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 {
@ -382,6 +406,11 @@ export const useConnectorDialog = () => {
if (!searchSpaceId) return;
setConnectingId("webcrawler-connector");
trackConnectorSetupStarted(
Number(searchSpaceId),
EnumConnectorName.WEBCRAWLER_CONNECTOR,
"webcrawler_quick_add"
);
try {
await createConnector({
data: {
@ -431,6 +460,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);
@ -441,6 +476,13 @@ export const useConnectorDialog = () => {
const handleConnectNonOAuth = useCallback(
(connectorType: string) => {
if (!searchSpaceId) return;
trackConnectorSetupStarted(
Number(searchSpaceId),
connectorType,
"non_oauth_click"
);
setConnectingConnectorType(connectorType);
},
[searchSpaceId]
@ -654,6 +696,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;

View file

@ -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<HTMLInputElement>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="select-none max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
<DialogHeader className="space-y-2 pb-2">
<div className="flex items-center gap-2 sm:gap-3">

View file

@ -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<number | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<number>>(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<string, FolderDisplay[]> = {};
@ -123,7 +127,7 @@ export function FolderPickerDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="select-none max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
<DialogHeader className="space-y-2 pb-2">
<div className="flex items-center gap-2 sm:gap-3">

View file

@ -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;

View file

@ -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,

View file

@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="ghost"

View file

@ -2,8 +2,10 @@
import { useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import dynamic from "next/dynamic";
import { useCallback, useMemo, useState } from "react";
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { Spinner } from "@/components/ui/spinner";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { InboxItem } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
@ -25,9 +27,20 @@ import {
Sidebar,
} from "../sidebar";
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
import { DocumentTabContent } from "../tabs/DocumentTabContent";
import { TabBar } from "../tabs/TabBar";
const DocumentTabContent = dynamic(
() => import("../tabs/DocumentTabContent").then((m) => ({ default: m.DocumentTabContent })),
{
ssr: false,
loading: () => (
<div className="flex-1 flex items-center justify-center h-full">
<Spinner size="lg" />
</div>
),
}
);
// Per-tab data source
interface TabDataSource {
items: InboxItem[];

View file

@ -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]

View file

@ -269,6 +269,34 @@ export function ModelSelector({
const searchInputRef = useRef<HTMLInputElement>(null);
const isMobile = useIsMobile();
const handleOpenChange = useCallback(
(next: boolean) => {
if (next) {
setSearchQuery("");
setSelectedProvider("all");
if (!isMobile) {
requestAnimationFrame(() => searchInputRef.current?.focus());
}
}
setOpen(next);
},
[isMobile]
);
const handleTabChange = useCallback(
(next: "llm" | "image" | "vision") => {
setActiveTab(next);
setSelectedProvider("all");
setSearchQuery("");
setFocusedIndex(-1);
setModelScrollPos("top");
if (open && !isMobile) {
requestAnimationFrame(() => searchInputRef.current?.focus());
}
},
[open, isMobile]
);
const handleModelListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
@ -292,43 +320,19 @@ export function ModelSelector({
[isMobile]
);
// Reset search + provider when tab changes
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger
useEffect(() => {
setSelectedProvider("all");
setSearchQuery("");
setFocusedIndex(-1);
setModelScrollPos("top");
}, [activeTab]);
// Reset on open
useEffect(() => {
if (open) {
setSearchQuery("");
setSelectedProvider("all");
}
}, [open]);
// Cmd/Ctrl+M shortcut (desktop only)
useEffect(() => {
if (isMobile) return;
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "m") {
e.preventDefault();
setOpen((prev) => !prev);
// setOpen((prev) => !prev);
handleOpenChange(!open);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isMobile]);
// Focus search input on open
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger to re-focus on tab switch
useEffect(() => {
if (open && !isMobile) {
requestAnimationFrame(() => searchInputRef.current?.focus());
}
}, [open, isMobile, activeTab]);
}, [isMobile, open, handleOpenChange]);
// ─── Data ───
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
@ -971,7 +975,8 @@ export function ModelSelector({
<button
key={value}
type="button"
onClick={() => setActiveTab(value)}
// onClick={() => setActiveTab(value)}
onClick={() => handleTabChange(value)}
className={cn(
"flex items-center justify-center gap-1.5 text-sm font-medium transition-all duration-200 border-b-[1.5px]",
activeTab === value
@ -1208,7 +1213,7 @@ export function ModelSelector({
// ─── Shell: Drawer on mobile, Popover on desktop ───
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger asChild>{triggerButton}</DrawerTrigger>
<DrawerContent className="max-h-[85vh]">
<DrawerHandle />
@ -1222,7 +1227,7 @@ export function ModelSelector({
}
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
<PopoverContent
className="w-[300px] md:w-[380px] p-0 rounded-lg shadow-lg overflow-hidden bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"

View file

@ -586,7 +586,7 @@ export const useThemeToggle = ({
}, []);
const toggleTheme = useCallback(() => {
setIsDark(!isDark);
setIsDark((prev) => !prev);
const animation = createAnimation(variant, start, blur, gifUrl);
@ -604,7 +604,7 @@ export const useThemeToggle = ({
}
document.startViewTransition(switchTheme);
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]);
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles]);
const setCrazyLightTheme = useCallback(() => {
setIsDark(false);

View file

@ -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<typeof getPagePurchasesResponse>;
export type CreateTokenCheckoutSessionRequest = z.infer<typeof createTokenCheckoutSessionRequest>;
export type CreateTokenCheckoutSessionResponse = z.infer<typeof createTokenCheckoutSessionResponse>;
export type TokenStripeStatusResponse = z.infer<typeof tokenStripeStatusResponse>;
export type TokenPurchaseStatus = z.infer<typeof tokenPurchaseStatusEnum>;
export type TokenPurchase = z.infer<typeof tokenPurchase>;
export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>;

View file

@ -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<string | undefined> {
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);
}
}

View file

@ -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<string, unknown>) {
}
}
/**
* Drop undefined values so PostHog doesn't log `"foo": undefined` noise.
*/
function compact<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<string, unknown>;
}
/**
* 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<string, unknown>) {
try {
@ -476,10 +557,19 @@ export function identifyUser(userId: string, properties?: Record<string, unknown
} catch {
// Silently ignore ad-blockers may break posthog
}
try {
if (typeof window !== "undefined" && window.electronAPI?.analyticsIdentify) {
void window.electronAPI.analyticsIdentify(userId, properties);
}
} catch {
// IPC errors must never break the app
}
}
/**
* Reset user identity (call on logout)
* Reset user identity (call on logout). Mirrors the reset into the
* Electron main process when running inside the desktop app.
*/
export function resetUser() {
try {
@ -487,4 +577,12 @@ export function resetUser() {
} catch {
// Silently ignore ad-blockers may break posthog
}
try {
if (typeof window !== "undefined" && window.electronAPI?.analyticsReset) {
void window.electronAPI.analyticsReset();
}
} catch {
// IPC errors must never break the app
}
}

View file

@ -102,9 +102,29 @@ interface ElectronAPI {
setShortcuts: (
config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }>
) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>;
// Launch on system startup
getAutoLaunch: () => Promise<{
enabled: boolean;
openAsHidden: boolean;
supported: boolean;
}>;
setAutoLaunch: (
enabled: boolean,
openAsHidden?: boolean
) => Promise<{ enabled: boolean; openAsHidden: boolean; supported: boolean }>;
// Active search space
getActiveSearchSpace: () => Promise<string | null>;
setActiveSearchSpace: (id: string) => Promise<void>;
// Analytics bridge (PostHog mirror into the Electron main process)
analyticsIdentify: (userId: string, properties?: Record<string, unknown>) => Promise<void>;
analyticsReset: () => Promise<void>;
analyticsCapture: (event: string, properties?: Record<string, unknown>) => Promise<void>;
getAnalyticsContext: () => Promise<{
distinctId: string;
machineId: string;
appVersion: string;
platform: string;
}>;
}
declare global {