Merge pull request #871 from MODSetter/dev

feat: fixed bad UI/UX
This commit is contained in:
Rohan Verma 2026-03-11 15:11:37 -07:00 committed by GitHub
commit 52248ac74a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 647 additions and 240 deletions

View file

@ -125,11 +125,14 @@ async def read_search_spaces(
If False (default), return all search spaces the user has access to. If False (default), return all search spaces the user has access to.
""" """
try: try:
# Exclude spaces that are pending background deletion
not_deleting = ~SearchSpace.name.startswith("[DELETING] ")
if owned_only: if owned_only:
# Return only search spaces where user is the original creator (user_id) # Return only search spaces where user is the original creator (user_id)
result = await session.execute( result = await session.execute(
select(SearchSpace) select(SearchSpace)
.filter(SearchSpace.user_id == user.id) .filter(SearchSpace.user_id == user.id, not_deleting)
.order_by(SearchSpace.id.asc()) .order_by(SearchSpace.id.asc())
.offset(skip) .offset(skip)
.limit(limit) .limit(limit)
@ -139,7 +142,7 @@ async def read_search_spaces(
result = await session.execute( result = await session.execute(
select(SearchSpace) select(SearchSpace)
.join(SearchSpaceMembership) .join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id) .filter(SearchSpaceMembership.user_id == user.id, not_deleting)
.order_by(SearchSpace.id.asc()) .order_by(SearchSpace.id.asc())
.offset(skip) .offset(skip)
.limit(limit) .limit(limit)

View file

@ -183,7 +183,7 @@ async def _cleanup_documents(
for doc_id in cleanup_doc_ids: for doc_id in cleanup_doc_ids:
try: try:
resp = await delete_document(client, headers, doc_id) resp = await delete_document(client, headers, doc_id)
if resp.status_code == 409: if resp.status_code != 200:
remaining_ids.append(doc_id) remaining_ids.append(doc_id)
except Exception: except Exception:
remaining_ids.append(doc_id) remaining_ids.append(doc_id)
@ -274,6 +274,15 @@ def _mock_external_apis(monkeypatch):
) )
@pytest.fixture(autouse=True)
def _mock_celery_delete_task(monkeypatch):
"""Mock Celery delete dispatch — no broker is available in CI."""
monkeypatch.setattr(
"app.tasks.celery_tasks.document_tasks.delete_document_task.delay",
lambda *args, **kwargs: None,
)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _mock_redis_heartbeat(monkeypatch): def _mock_redis_heartbeat(monkeypatch):
"""Mock Redis heartbeat — Redis is an external infrastructure boundary.""" """Mock Redis heartbeat — Redis is an external infrastructure boundary."""

View file

@ -320,6 +320,7 @@ export function DocumentsTableShell({
sortDesc, sortDesc,
onSortChange, onSortChange,
deleteDocument, deleteDocument,
bulkDeleteDocuments,
searchSpaceId, searchSpaceId,
hasMore = false, hasMore = false,
loadingMore = false, loadingMore = false,
@ -336,6 +337,7 @@ export function DocumentsTableShell({
sortDesc: boolean; sortDesc: boolean;
onSortChange: (key: SortKey) => void; onSortChange: (key: SortKey) => void;
deleteDocument: (id: number) => Promise<boolean>; deleteDocument: (id: number) => Promise<boolean>;
bulkDeleteDocuments?: (ids: number[]) => Promise<{ success: number; failed: number }>;
searchSpaceId: string; searchSpaceId: string;
hasMore?: boolean; hasMore?: boolean;
loadingMore?: boolean; loadingMore?: boolean;
@ -370,6 +372,8 @@ export function DocumentsTableShell({
const [deleteDoc, setDeleteDoc] = useState<Document | null>(null); const [deleteDoc, setDeleteDoc] = useState<Document | null>(null);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [mobileActionDoc, setMobileActionDoc] = useState<Document | null>(null); const [mobileActionDoc, setMobileActionDoc] = useState<Document | null>(null);
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const router = useRouter(); const router = useRouter();
const desktopSentinelRef = useRef<HTMLDivElement>(null); const desktopSentinelRef = useRef<HTMLDivElement>(null);
@ -496,6 +500,59 @@ export function DocumentsTableShell({
const onSortHeader = (key: SortKey) => onSortChange(key); const onSortHeader = (key: SortKey) => onSortChange(key);
const deletableSelectedIds = React.useMemo(() => {
if (!mentionedDocIds || mentionedDocIds.size === 0) return [];
return sorted
.filter((doc) => {
if (!mentionedDocIds.has(doc.id)) return false;
const state = doc.status?.state;
return (
state !== "pending" &&
state !== "processing" &&
!NON_DELETABLE_DOCUMENT_TYPES.includes(
doc.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
)
);
})
.map((doc) => doc.id);
}, [sorted, mentionedDocIds]);
const hasDeletableSelection = deletableSelectedIds.length > 0;
const handleBulkDelete = useCallback(async () => {
if (deletableSelectedIds.length === 0) return;
setIsBulkDeleting(true);
try {
if (bulkDeleteDocuments) {
const { success, failed } = await bulkDeleteDocuments(deletableSelectedIds);
if (success > 0) {
toast.success(`Deleted ${success} document${success !== 1 ? "s" : ""}`);
}
if (failed > 0) {
toast.error(`Failed to delete ${failed} document${failed !== 1 ? "s" : ""}`);
}
} else {
const results = await Promise.allSettled(
deletableSelectedIds.map((id) => deleteDocument(id))
);
const successCount = results.filter(
(r) => r.status === "fulfilled" && r.value === true
).length;
const failCount = deletableSelectedIds.length - successCount;
if (successCount > 0) {
toast.success(`Deleted ${successCount} document${successCount !== 1 ? "s" : ""}`);
}
if (failCount > 0) {
toast.error(`Failed to delete ${failCount} document${failCount !== 1 ? "s" : ""}`);
}
}
} catch {
toast.error("Failed to delete documents");
}
setIsBulkDeleting(false);
setBulkDeleteConfirmOpen(false);
}, [deletableSelectedIds, bulkDeleteDocuments, deleteDocument]);
return ( return (
<div className="bg-sidebar overflow-hidden select-none border-t border-border/50 flex-1 flex flex-col min-h-0"> <div className="bg-sidebar overflow-hidden select-none border-t border-border/50 flex-1 flex flex-col min-h-0">
{/* Desktop Table View */} {/* Desktop Table View */}
@ -530,7 +587,22 @@ export function DocumentsTableShell({
</span> </span>
</TableHead> </TableHead>
<TableHead className="w-12 text-center h-8 pl-0 pr-3"> <TableHead className="w-12 text-center h-8 pl-0 pr-3">
{hasDeletableSelection ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setBulkDeleteConfirmOpen(true)}
className="inline-flex items-center justify-center h-6 w-6 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent>Delete {deletableSelectedIds.length} selected</TooltipContent>
</Tooltip>
) : (
<span className="text-xs font-medium text-muted-foreground">Status</span> <span className="text-xs font-medium text-muted-foreground">Status</span>
)}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -742,6 +814,22 @@ export function DocumentsTableShell({
ref={mobileScrollRef} ref={mobileScrollRef}
className="md:hidden divide-y divide-border/50 flex-1 overflow-auto" className="md:hidden divide-y divide-border/50 flex-1 overflow-auto"
> >
{hasDeletableSelection && (
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border/50 sticky top-0 z-10">
<span className="text-xs text-muted-foreground">
{deletableSelectedIds.length} deletable selected
</span>
<Button
variant="destructive"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() => setBulkDeleteConfirmOpen(true)}
>
<Trash2 size={12} className="mr-1" />
Delete
</Button>
</div>
)}
{sorted.map((doc) => { {sorted.map((doc) => {
const isMentioned = mentionedDocIds?.has(doc.id) ?? false; const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
const canInteract = isSelectable(doc); const canInteract = isSelectable(doc);
@ -957,6 +1045,41 @@ export function DocumentsTableShell({
</div> </div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
{/* Bulk Delete Confirmation Dialog */}
<AlertDialog
open={bulkDeleteConfirmOpen}
onOpenChange={(open) => !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete {deletableSelectedIds.length} document
{deletableSelectedIds.length !== 1 ? "s" : ""}?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone.{" "}
{deletableSelectedIds.length === 1
? "This document"
: `These ${deletableSelectedIds.length} documents`}{" "}
will be permanently deleted from your search space.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBulkDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleBulkDelete();
}}
disabled={isBulkDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isBulkDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }

View file

@ -1,6 +1,6 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { atomWithQuery } from "jotai-tanstack-query"; import { atomWithQuery } from "jotai-tanstack-query";
import { agentToolsApiService, type AgentToolInfo } from "@/lib/apis/agent-tools-api.service"; import { type AgentToolInfo, agentToolsApiService } from "@/lib/apis/agent-tools-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";

View file

@ -1,5 +1,4 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
interface ReportPanelState { interface ReportPanelState {
@ -25,11 +24,14 @@ export const reportPanelAtom = atom<ReportPanelState>(initialState);
/** Derived read-only atom for checking if panel is open */ /** Derived read-only atom for checking if panel is open */
export const reportPanelOpenAtom = atom((get) => get(reportPanelAtom).isOpen); export const reportPanelOpenAtom = atom((get) => get(reportPanelAtom).isOpen);
/** Snapshot of `rightPanelCollapsedAtom` taken before the report opens */
const preReportCollapsedAtom = atom<boolean | null>(null);
/** Action atom to open the report panel with a specific report */ /** Action atom to open the report panel with a specific report */
export const openReportPanelAtom = atom( export const openReportPanelAtom = atom(
null, null,
( (
_get, get,
set, set,
{ {
reportId, reportId,
@ -38,6 +40,9 @@ export const openReportPanelAtom = atom(
shareToken, shareToken,
}: { reportId: number; title: string; wordCount?: number; shareToken?: string | null } }: { reportId: number; title: string; wordCount?: number; shareToken?: string | null }
) => { ) => {
if (!get(reportPanelAtom).isOpen) {
set(preReportCollapsedAtom, get(rightPanelCollapsedAtom));
}
set(reportPanelAtom, { set(reportPanelAtom, {
isOpen: true, isOpen: true,
reportId, reportId,
@ -47,12 +52,16 @@ export const openReportPanelAtom = atom(
}); });
set(rightPanelTabAtom, "report"); set(rightPanelTabAtom, "report");
set(rightPanelCollapsedAtom, false); set(rightPanelCollapsedAtom, false);
set(documentsSidebarOpenAtom, true);
} }
); );
/** Action atom to close the report panel */ /** Action atom to close the report panel */
export const closeReportPanelAtom = atom(null, (_get, set) => { export const closeReportPanelAtom = atom(null, (get, set) => {
set(reportPanelAtom, initialState); set(reportPanelAtom, initialState);
set(rightPanelTabAtom, "sources"); set(rightPanelTabAtom, "sources");
const prev = get(preReportCollapsedAtom);
if (prev !== null) {
set(rightPanelCollapsedAtom, prev);
set(preReportCollapsedAtom, null);
}
}); });

View file

@ -258,7 +258,10 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
</TooltipIconButton> </TooltipIconButton>
)} )}
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none"> <DialogContent
onFocusOutside={(e) => e.preventDefault()}
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none"
>
<DialogTitle className="sr-only">Manage Connectors</DialogTitle> <DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */} {/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? ( {isYouTubeView && searchSpaceId ? (

View file

@ -123,11 +123,7 @@ export const useConnectorDialog = () => {
}, []); }, []);
const handleAutoIndex = useCallback( const handleAutoIndex = useCallback(
async ( async (connector: SearchSourceConnector, connectorTitle: string, connectorType: string) => {
connector: SearchSourceConnector,
connectorTitle: string,
connectorType: string
) => {
if (!searchSpaceId || isAutoIndexingRef.current) return; if (!searchSpaceId || isAutoIndexingRef.current) return;
isAutoIndexingRef.current = true; isAutoIndexingRef.current = true;
@ -159,12 +155,10 @@ export const useConnectorDialog = () => {
}, },
}); });
trackIndexWithDateRangeStarted( trackIndexWithDateRangeStarted(Number(searchSpaceId), connectorType, connector.id, {
Number(searchSpaceId), hasStartDate: true,
connectorType, hasEndDate: true,
connector.id, });
{ hasStartDate: true, hasEndDate: true }
);
toast.success(`${connectorTitle} connected!`, { toast.success(`${connectorTitle} connected!`, {
id: toastId, id: toastId,
@ -187,6 +181,24 @@ export const useConnectorDialog = () => {
[searchSpaceId, indexConnector, updateConnector, refetchAllConnectors] [searchSpaceId, indexConnector, updateConnector, refetchAllConnectors]
); );
// When the dialog is opened externally (via setConnectorDialogOpen atom from
// thread.tsx / DocumentsSidebar.tsx), the URL is not updated. Sync it here
// so that other handlers that read window.location.href see modal=connectors.
const activeTabRef = useRef(activeTab);
activeTabRef.current = activeTab;
useEffect(() => {
if (isOpen) {
const url = new URL(window.location.href);
const modalParam = url.searchParams.get("modal");
const tabParam = url.searchParams.get("tab");
if (modalParam !== "connectors" || (tabParam !== "all" && tabParam !== "active")) {
url.searchParams.set("modal", "connectors");
url.searchParams.set("tab", activeTabRef.current);
window.history.replaceState({ modal: true }, "", url.toString());
}
}
}, [isOpen]);
// Synchronize state with URL query params // Synchronize state with URL query params
useEffect(() => { useEffect(() => {
try { try {
@ -317,8 +329,14 @@ export const useConnectorDialog = () => {
} }
} }
} else { } else {
setIsOpen(false); // Do NOT call setIsOpen(false) here. Closing the dialog is handled
// Clear indexing config when modal is closed // explicitly by handleOpenChange and the individual action handlers.
// Relying on URL params to close the dialog caused a race condition
// where Next.js router updates from tab switches briefly produced
// stale searchParams without the "modal" key, closing the popup.
// Still clean up sub-view state when the modal param is gone
// (e.g. after browser back navigation or explicit handler URL cleanup).
if (indexingConfig) { if (indexingConfig) {
setIndexingConfig(null); setIndexingConfig(null);
setIndexingConnector(null); setIndexingConnector(null);
@ -331,7 +349,6 @@ export const useConnectorDialog = () => {
setIsScrolled(false); setIsScrolled(false);
setSearchQuery(""); setSearchQuery("");
} }
// Clear editing connector when modal is closed
if (editingConnector) { if (editingConnector) {
setEditingConnector(null); setEditingConnector(null);
setConnectorName(null); setConnectorName(null);
@ -344,15 +361,12 @@ export const useConnectorDialog = () => {
setIsScrolled(false); setIsScrolled(false);
setSearchQuery(""); setSearchQuery("");
} }
// Clear connecting connector type when modal is closed
if (connectingConnectorType) { if (connectingConnectorType) {
setConnectingConnectorType(null); setConnectingConnectorType(null);
} }
// Clear viewing accounts type when modal is closed
if (viewingAccountsType) { if (viewingAccountsType) {
setViewingAccountsType(null); setViewingAccountsType(null);
} }
// Clear YouTube view when modal is closed (handled by view param check)
} }
} catch (error) { } catch (error) {
// Invalid query params - log but don't crash // Invalid query params - log but don't crash
@ -412,6 +426,7 @@ export const useConnectorDialog = () => {
if (earlyConnector && AUTO_INDEX_CONNECTOR_TYPES.has(earlyConnector.connectorType)) { if (earlyConnector && AUTO_INDEX_CONNECTOR_TYPES.has(earlyConnector.connectorType)) {
toast.loading(`Setting up ${earlyConnector.title}...`, { id: "auto-index" }); toast.loading(`Setting up ${earlyConnector.title}...`, { id: "auto-index" });
setIsOpen(false);
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.delete("success"); url.searchParams.delete("success");
url.searchParams.delete("connector"); url.searchParams.delete("connector");
@ -795,6 +810,8 @@ export const useConnectorDialog = () => {
: `${connectorTitle} connected and syncing started!`; : `${connectorTitle} connected and syncing started!`;
toast.success(successMessage); toast.success(successMessage);
// Close dialog and clean up URL
setIsOpen(false);
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.delete("modal"); url.searchParams.delete("modal");
url.searchParams.delete("tab"); url.searchParams.delete("tab");
@ -860,7 +877,8 @@ export const useConnectorDialog = () => {
// Refresh connectors list before closing modal // Refresh connectors list before closing modal
await refetchAllConnectors(); await refetchAllConnectors();
// Close modal and return to main view // Close dialog and clean up URL
setIsOpen(false);
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.delete("modal"); url.searchParams.delete("modal");
url.searchParams.delete("tab"); url.searchParams.delete("tab");
@ -894,6 +912,7 @@ export const useConnectorDialog = () => {
updateConnector, updateConnector,
indexConnector, indexConnector,
router, router,
setIsOpen,
] ]
); );
@ -1124,7 +1143,8 @@ export const useConnectorDialog = () => {
toast.success(`${indexingConfig.connectorTitle} indexing started`); toast.success(`${indexingConfig.connectorTitle} indexing started`);
// Update URL - the effect will handle closing the modal and clearing state // Close dialog and clean up URL
setIsOpen(false);
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.delete("modal"); url.searchParams.delete("modal");
url.searchParams.delete("tab"); url.searchParams.delete("tab");
@ -1156,12 +1176,14 @@ export const useConnectorDialog = () => {
enableSummary, enableSummary,
router, router,
indexingConnectorConfig, indexingConnectorConfig,
setIsOpen,
] ]
); );
// Handle skipping indexing // Handle skipping indexing
const handleSkipIndexing = useCallback(() => { const handleSkipIndexing = useCallback(() => {
// Update URL - the effect will handle closing the modal and clearing state // Close dialog and clean up URL
setIsOpen(false);
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.delete("modal"); url.searchParams.delete("modal");
url.searchParams.delete("tab"); url.searchParams.delete("tab");
@ -1169,7 +1191,7 @@ export const useConnectorDialog = () => {
url.searchParams.delete("connector"); url.searchParams.delete("connector");
url.searchParams.delete("view"); url.searchParams.delete("view");
router.replace(url.pathname + url.search, { scroll: false }); router.replace(url.pathname + url.search, { scroll: false });
}, [router]); }, [router, setIsOpen]);
// Handle starting edit mode // Handle starting edit mode
const handleStartEdit = useCallback( const handleStartEdit = useCallback(
@ -1411,7 +1433,8 @@ export const useConnectorDialog = () => {
: indexingDescription, : indexingDescription,
}); });
// Update URL - the effect will handle closing the modal and clearing state // Close dialog and clean up URL
setIsOpen(false);
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.delete("modal"); url.searchParams.delete("modal");
url.searchParams.delete("tab"); url.searchParams.delete("tab");
@ -1445,6 +1468,7 @@ export const useConnectorDialog = () => {
router, router,
connectorConfig, connectorConfig,
connectorName, connectorName,
setIsOpen,
] ]
); );
@ -1481,7 +1505,8 @@ export const useConnectorDialog = () => {
url.searchParams.set("view", "mcp-list"); url.searchParams.set("view", "mcp-list");
url.searchParams.delete("connectorId"); url.searchParams.delete("connectorId");
} else { } else {
// Close modal for all other cases // Close dialog for all other cases
setIsOpen(false);
url.searchParams.delete("modal"); url.searchParams.delete("modal");
url.searchParams.delete("tab"); url.searchParams.delete("tab");
url.searchParams.delete("view"); url.searchParams.delete("view");
@ -1500,7 +1525,7 @@ export const useConnectorDialog = () => {
setIsDisconnecting(false); setIsDisconnecting(false);
} }
}, },
[editingConnector, searchSpaceId, deleteConnector, router, cameFromMCPList] [editingConnector, searchSpaceId, deleteConnector, router, cameFromMCPList, setIsOpen]
); );
// Handle quick index (index with selected date range, or backend defaults if none selected) // Handle quick index (index with selected date range, or backend defaults if none selected)
@ -1640,12 +1665,13 @@ export const useConnectorDialog = () => {
[activeTab, isStartingIndexing, isDisconnecting, isSaving, isCreatingConnector, setIsOpen] [activeTab, isStartingIndexing, isDisconnecting, isSaving, isCreatingConnector, setIsOpen]
); );
// Handle tab change // Handle tab change — only update React state.
// Avoid window.history.replaceState here: Next.js intercepts it, triggers a
// searchParams update/transition, and the resulting concurrent re-render can
// cause Radix Dialog's DismissableLayer to detect a transient focus-outside
// event, which fires onOpenChange(false) and closes the dialog.
const handleTabChange = useCallback((value: string) => { const handleTabChange = useCallback((value: string) => {
setActiveTab(value); setActiveTab(value);
const url = new URL(window.location.href);
url.searchParams.set("tab", value);
window.history.replaceState({ modal: true }, "", url.toString());
}, []); }, []);
// Handle scroll // Handle scroll

View file

@ -28,6 +28,13 @@ import {
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import {
agentToolsAtom,
disabledToolsAtom,
enabledToolCountAtom,
hydrateDisabledToolsAtom,
toggleToolAtom,
} from "@/atoms/agent-tools/agent-tools.atoms";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { import {
mentionedDocumentsAtom, mentionedDocumentsAtom,
@ -66,20 +73,14 @@ import {
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document } from "@/contracts/types/document.types"; import type { Document } from "@/contracts/types/document.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments"; import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsElectric } from "@/hooks/use-comments-electric"; import { useCommentsElectric } from "@/hooks/use-comments-electric";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useMediaQuery } from "@/hooks/use-media-query";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
agentToolsAtom,
disabledToolsAtom,
enabledToolCountAtom,
hydrateDisabledToolsAtom,
toggleToolAtom,
} from "@/atoms/agent-tools/agent-tools.atoms";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** Placeholder texts that cycle in new chats when input is empty */ /** Placeholder texts that cycle in new chats when input is empty */
@ -562,7 +563,16 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom); const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const sidebarDocs = useAtomValue(sidebarSelectedDocumentsAtom); const sidebarDocs = useAtomValue(sidebarSelectedDocumentsAtom);
const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom); const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false); const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false);
const isDesktop = useMediaQuery("(min-width: 640px)");
const [toolsScrollPos, setToolsScrollPos] = useState<"top" | "middle" | "bottom">("top");
const handleToolsScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setToolsScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const isComposerTextEmpty = useAssistantState(({ composer }) => { const isComposerTextEmpty = useAssistantState(({ composer }) => {
const text = composer.text?.trim() || ""; const text = composer.text?.trim() || "";
return text.length === 0; return text.length === 0;
@ -614,32 +624,46 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
</TooltipIconButton> </TooltipIconButton>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
side="top" side="bottom"
align="start" align="start"
sideOffset={12} sideOffset={12}
className="w-[calc(100vw-2rem)] max-w-80 sm:w-80 p-0" className="w-[calc(100vw-2rem)] max-w-56 sm:max-w-72 sm:w-72 p-0 select-none"
onOpenAutoFocus={(e) => e.preventDefault()}
> >
<div className="flex items-center justify-between px-3 py-2.5 border-b"> <div className="flex items-center justify-between px-2.5 py-2 sm:px-3 sm:py-2.5 border-b">
<span className="text-sm font-medium">Agent Tools</span> <span className="text-xs sm:text-sm font-medium">Agent Tools</span>
<span className="text-xs text-muted-foreground"> <span className="text-[10px] sm:text-xs text-muted-foreground">
{enabledCount}/{agentTools?.length ?? 0} enabled {enabledCount}/{agentTools?.length ?? 0} enabled
</span> </span>
</div> </div>
<div className="max-h-64 overflow-y-auto py-1"> <div
className="max-h-48 sm:max-h-64 overflow-y-auto py-0.5 sm:py-1"
onScroll={handleToolsScroll}
style={{
maskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{agentTools?.map((tool) => { {agentTools?.map((tool) => {
const isDisabled = disabledTools.includes(tool.name); const isDisabled = disabledTools.includes(tool.name);
return ( const row = (
<Tooltip key={tool.name}> <label className="flex items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 cursor-pointer hover:bg-muted-foreground/10 transition-colors">
<TooltipTrigger asChild> <span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
<label className="flex items-center gap-3 px-3 py-1.5 cursor-pointer hover:bg-muted-foreground/10 transition-colors"> {formatToolName(tool.name)}
<span className="flex-1 min-w-0 text-sm font-medium truncate">{formatToolName(tool.name)}</span> </span>
<Switch <Switch
checked={!isDisabled} checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)} onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0 scale-75" className="shrink-0 scale-[0.6] sm:scale-75"
/> />
</label> </label>
</TooltipTrigger> );
if (!isDesktop) {
return <div key={tool.name}>{row}</div>;
}
return (
<Tooltip key={tool.name}>
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="right" className="max-w-64 text-xs"> <TooltipContent side="right" className="max-w-64 text-xs">
{tool.description} {tool.description}
</TooltipContent> </TooltipContent>
@ -654,6 +678,19 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{!isDesktop && (
<TooltipIconButton
tooltip="Manage connectors"
side="bottom"
variant="ghost"
size="icon"
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
aria-label="Manage connectors"
onClick={() => setConnectorDialogOpen(true)}
>
<Unplug className="size-4" />
</TooltipIconButton>
)}
{sidebarDocs.length > 0 && ( {sidebarDocs.length > 0 && (
<button <button
type="button" type="button"

View file

@ -83,7 +83,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
// Atoms // Atoms
const { data: user } = useAtomValue(currentUserAtom); const { data: user } = useAtomValue(currentUserAtom);
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom); const {
data: searchSpacesData,
refetch: refetchSearchSpaces,
isSuccess: searchSpacesLoaded,
} = useAtomValue(searchSpacesAtom);
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom); const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
const currentThreadState = useAtomValue(currentThreadAtom); const currentThreadState = useAtomValue(currentThreadAtom);
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom); const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
@ -276,6 +280,26 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null; return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null;
}, [searchSpaceId, searchSpaces]); }, [searchSpaceId, searchSpaces]);
// Safety redirect: if the current search space is no longer in the user's list
// (e.g. deleted by background task, membership revoked), redirect to a valid space.
useEffect(() => {
if (!searchSpacesLoaded || !searchSpaceId || isDeletingSearchSpace || isLeavingSearchSpace)
return;
if (searchSpaces.length > 0 && !activeSearchSpace) {
router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`);
} else if (searchSpaces.length === 0 && searchSpacesLoaded) {
router.replace("/dashboard");
}
}, [
searchSpacesLoaded,
searchSpaceId,
searchSpaces,
activeSearchSpace,
isDeletingSearchSpace,
isLeavingSearchSpace,
router,
]);
// Transform and split chats into private and shared based on visibility // Transform and split chats into private and shared based on visibility
const { myChats, sharedChats } = useMemo(() => { const { myChats, sharedChats } = useMemo(() => {
if (!threadsData?.threads) return { myChats: [], sharedChats: [] }; if (!threadsData?.threads) return { myChats: [], sharedChats: [] };
@ -384,53 +408,59 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
setIsDeletingSearchSpace(true); setIsDeletingSearchSpace(true);
try { try {
await deleteSearchSpace({ id: searchSpaceToDelete.id }); await deleteSearchSpace({ id: searchSpaceToDelete.id });
refetchSearchSpaces();
if (Number(searchSpaceId) === searchSpaceToDelete.id && searchSpaces.length > 1) { const isCurrentSpace = Number(searchSpaceId) === searchSpaceToDelete.id;
const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToDelete.id);
if (remaining.length > 0) { // Await refetch so we have the freshest list (backend now hides [DELETING] spaces)
router.push(`/dashboard/${remaining[0].id}/new-chat`); const result = await refetchSearchSpaces();
} const updatedSpaces = (result.data ?? []).filter((s) => s.id !== searchSpaceToDelete.id);
} else if (searchSpaces.length === 1) {
if (isCurrentSpace) {
if (updatedSpaces.length > 0) {
router.push(`/dashboard/${updatedSpaces[0].id}/new-chat`);
} else {
router.push("/dashboard"); router.push("/dashboard");
} }
}
} catch (error) { } catch (error) {
console.error("Error deleting search space:", error); console.error("Error deleting search space:", error);
toast.error(
t.has("delete_space_error") ? t("delete_space_error") : "Failed to delete search space"
);
} finally { } finally {
setIsDeletingSearchSpace(false); setIsDeletingSearchSpace(false);
setShowDeleteSearchSpaceDialog(false); setShowDeleteSearchSpaceDialog(false);
setSearchSpaceToDelete(null); setSearchSpaceToDelete(null);
} }
}, [ }, [searchSpaceToDelete, deleteSearchSpace, refetchSearchSpaces, searchSpaceId, router, t]);
searchSpaceToDelete,
deleteSearchSpace,
refetchSearchSpaces,
searchSpaceId,
searchSpaces,
router,
]);
const confirmLeaveSearchSpace = useCallback(async () => { const confirmLeaveSearchSpace = useCallback(async () => {
if (!searchSpaceToLeave) return; if (!searchSpaceToLeave) return;
setIsLeavingSearchSpace(true); setIsLeavingSearchSpace(true);
try { try {
await searchSpacesApiService.leaveSearchSpace(searchSpaceToLeave.id); await searchSpacesApiService.leaveSearchSpace(searchSpaceToLeave.id);
refetchSearchSpaces();
if (Number(searchSpaceId) === searchSpaceToLeave.id && searchSpaces.length > 1) { const isCurrentSpace = Number(searchSpaceId) === searchSpaceToLeave.id;
const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToLeave.id);
if (remaining.length > 0) { const result = await refetchSearchSpaces();
router.push(`/dashboard/${remaining[0].id}/new-chat`); const updatedSpaces = (result.data ?? []).filter((s) => s.id !== searchSpaceToLeave.id);
}
} else if (searchSpaces.length === 1) { if (isCurrentSpace) {
if (updatedSpaces.length > 0) {
router.push(`/dashboard/${updatedSpaces[0].id}/new-chat`);
} else {
router.push("/dashboard"); router.push("/dashboard");
} }
}
} catch (error) { } catch (error) {
console.error("Error leaving search space:", error); console.error("Error leaving search space:", error);
toast.error(t.has("leave_error") ? t("leave_error") : "Failed to leave search space");
} finally { } finally {
setIsLeavingSearchSpace(false); setIsLeavingSearchSpace(false);
setShowLeaveSearchSpaceDialog(false); setShowLeaveSearchSpaceDialog(false);
setSearchSpaceToLeave(null); setSearchSpaceToLeave(null);
} }
}, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, searchSpaces, router]); }, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, router, t]);
const handleNavItemClick = useCallback( const handleNavItemClick = useCallback(
(item: NavItem) => { (item: NavItem) => {

View file

@ -309,7 +309,7 @@ export function LayoutShell({
/> />
<motion.main <motion.main
layout="position" layout={isResizing ? false : "position"}
style={{ contain: "inline-size" }} style={{ contain: "inline-size" }}
className="flex-1 flex flex-col min-w-0" className="flex-1 flex flex-col min-w-0"
> >

View file

@ -8,7 +8,6 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useLongPress } from "@/hooks/use-long-press"; import { useLongPress } from "@/hooks/use-long-press";
@ -20,6 +19,8 @@ interface ChatListItemProps {
name: string; name: string;
isActive?: boolean; isActive?: boolean;
archived?: boolean; archived?: boolean;
dropdownOpen?: boolean;
onDropdownOpenChange?: (open: boolean) => void;
onClick?: () => void; onClick?: () => void;
onRename?: () => void; onRename?: () => void;
onArchive?: () => void; onArchive?: () => void;
@ -30,6 +31,8 @@ export function ChatListItem({
name, name,
isActive, isActive,
archived, archived,
dropdownOpen: controlledOpen,
onDropdownOpenChange,
onClick, onClick,
onRename, onRename,
onArchive, onArchive,
@ -37,11 +40,13 @@ export function ChatListItem({
}: ChatListItemProps) { }: ChatListItemProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [dropdownOpen, setDropdownOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
const dropdownOpen = controlledOpen ?? internalOpen;
const setDropdownOpen = onDropdownOpenChange ?? setInternalOpen;
const animatedName = useTypewriter(name); const animatedName = useTypewriter(name);
const { handlers: longPressHandlers, wasLongPress } = useLongPress( const { handlers: longPressHandlers, wasLongPress } = useLongPress(
useCallback(() => setDropdownOpen(true), []) useCallback(() => setDropdownOpen(true), [setDropdownOpen])
); );
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
@ -68,12 +73,12 @@ export function ChatListItem({
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */} {/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
<div <div
className={cn( className={cn(
"absolute right-0 top-0 bottom-0 flex items-center pr-1 pl-6 rounded-r-md", "pointer-events-none absolute right-0 top-0 bottom-0 flex items-center pr-1 pl-6 rounded-r-md",
isActive isActive
? "bg-gradient-to-l from-accent from-60% to-transparent" ? "bg-gradient-to-l from-accent from-60% to-transparent"
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent", : "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
isMobile isMobile
? "opacity-0 pointer-events-none" ? "opacity-0"
: isActive : isActive
? "opacity-100" ? "opacity-100"
: "opacity-0 group-hover/item:opacity-100" : "opacity-0 group-hover/item:opacity-100"
@ -81,7 +86,7 @@ export function ChatListItem({
> >
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}> <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6"> <Button variant="ghost" size="icon" className="pointer-events-auto h-6 w-6">
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" /> <MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
<span className="sr-only">{t("more_options")}</span> <span className="sr-only">{t("more_options")}</span>
</Button> </Button>
@ -118,7 +123,6 @@ export function ChatListItem({
)} )}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{onArchive && onDelete && <DropdownMenuSeparator />}
{onDelete && ( {onDelete && (
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {

View file

@ -102,6 +102,7 @@ export function DocumentsSidebar({
loadingMore: realtimeLoadingMore, loadingMore: realtimeLoadingMore,
hasMore: realtimeHasMore, hasMore: realtimeHasMore,
loadMore: realtimeLoadMore, loadMore: realtimeLoadMore,
removeItems: realtimeRemoveItems,
error: realtimeError, error: realtimeError,
} = useDocuments(searchSpaceId, activeTypes, sortKey, sortDesc ? "desc" : "asc"); } = useDocuments(searchSpaceId, activeTypes, sortKey, sortDesc ? "desc" : "asc");
@ -137,6 +138,7 @@ export function DocumentsSidebar({
await deleteDocumentMutation({ id }); await deleteDocumentMutation({ id });
toast.success(t("delete_success") || "Document deleted"); toast.success(t("delete_success") || "Document deleted");
setSidebarDocs((prev) => prev.filter((d) => d.id !== id)); setSidebarDocs((prev) => prev.filter((d) => d.id !== id));
realtimeRemoveItems([id]);
if (isSearchMode) { if (isSearchMode) {
searchRemoveItems([id]); searchRemoveItems([id]);
} }
@ -146,7 +148,37 @@ export function DocumentsSidebar({
return false; return false;
} }
}, },
[deleteDocumentMutation, isSearchMode, t, searchRemoveItems, setSidebarDocs] [
deleteDocumentMutation,
isSearchMode,
t,
searchRemoveItems,
realtimeRemoveItems,
setSidebarDocs,
]
);
const handleBulkDeleteDocuments = useCallback(
async (ids: number[]): Promise<{ success: number; failed: number }> => {
const successIds: number[] = [];
const results = await Promise.allSettled(
ids.map(async (id) => {
await deleteDocumentMutation({ id });
successIds.push(id);
})
);
if (successIds.length > 0) {
setSidebarDocs((prev) => prev.filter((d) => !successIds.includes(d.id)));
realtimeRemoveItems(successIds);
if (isSearchMode) {
searchRemoveItems(successIds);
}
}
const success = results.filter((r) => r.status === "fulfilled").length;
const failed = results.filter((r) => r.status === "rejected").length;
return { success, failed };
},
[deleteDocumentMutation, isSearchMode, searchRemoveItems, realtimeRemoveItems, setSidebarDocs]
); );
const sortKeyRef = useRef(sortKey); const sortKeyRef = useRef(sortKey);
@ -233,34 +265,69 @@ export function DocumentsSidebar({
</div> </div>
{/* Connected tools strip */} {/* Connected tools strip */}
<div className="shrink-0 mx-4 mt-2 mb-3 flex select-none items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2"> <div className="shrink-0 mx-4 mt-2 mb-3 flex select-none items-center gap-2 rounded-lg border bg-muted/50 transition-colors hover:bg-muted/80">
<button <button
type="button" type="button"
onClick={() => setConnectorDialogOpen(true)} onClick={() => setConnectorDialogOpen(true)}
className="flex items-center gap-2 min-w-0 flex-1 text-left" className="flex items-center gap-2 min-w-0 flex-1 text-left px-3 py-2"
> >
<Unplug className="size-4 shrink-0 text-muted-foreground" /> <Unplug className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-xs text-muted-foreground"> <span className="truncate text-xs text-muted-foreground">
{connectorCount > 0 ? "Manage connectors" : "Connect connectors"} {connectorCount > 0 ? "Manage connectors" : "Connect your connectors"}
</span> </span>
{connectorCount > 0 && ( {connectorCount > 0 && (
<span className="ml-auto shrink-0 text-xs font-medium text-muted-foreground">{connectorCount}</span> <span className="shrink-0 rounded-full bg-muted-foreground/15 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{connectorCount}
</span>
)} )}
<AvatarGroup className="ml-auto shrink-0"> <AvatarGroup className="ml-auto shrink-0">
{SHOWCASE_CONNECTORS.map(({ type, label }, i) => ( {connectorCount > 0 && connectors
<Tooltip key={type}> ? connectors.slice(0, isMobile ? 5 : 9).map((connector, i) => {
<TooltipTrigger asChild> const avatar = (
<Avatar className="size-6" style={{ zIndex: SHOWCASE_CONNECTORS.length - i }}> <Avatar
key={connector.id}
className="size-6"
style={{ zIndex: Math.max(9 - i, 1) }}
>
<AvatarFallback className="bg-muted text-[10px]">
{getConnectorIcon(connector.connector_type, "size-3.5")}
</AvatarFallback>
</Avatar>
);
if (isMobile) return avatar;
return (
<Tooltip key={connector.id}>
<TooltipTrigger asChild>{avatar}</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{connector.name}
</TooltipContent>
</Tooltip>
);
})
: (isMobile ? SHOWCASE_CONNECTORS.slice(0, 5) : SHOWCASE_CONNECTORS).map(
({ type, label }, i) => {
const avatar = (
<Avatar
key={type}
className="size-6"
style={{ zIndex: SHOWCASE_CONNECTORS.length - i }}
>
<AvatarFallback className="bg-muted text-[10px]"> <AvatarFallback className="bg-muted text-[10px]">
{getConnectorIcon(type, "size-3.5")} {getConnectorIcon(type, "size-3.5")}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</TooltipTrigger> );
if (isMobile) return avatar;
return (
<Tooltip key={type}>
<TooltipTrigger asChild>{avatar}</TooltipTrigger>
<TooltipContent side="top" className="text-xs"> <TooltipContent side="top" className="text-xs">
{label} {label}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
))} );
}
)}
</AvatarGroup> </AvatarGroup>
</button> </button>
</div> </div>
@ -284,6 +351,7 @@ export function DocumentsSidebar({
sortDesc={sortDesc} sortDesc={sortDesc}
onSortChange={handleSortChange} onSortChange={handleSortChange}
deleteDocument={handleDeleteDocument} deleteDocument={handleDeleteDocument}
bulkDeleteDocuments={handleBulkDeleteDocuments}
searchSpaceId={String(searchSpaceId)} searchSpaceId={String(searchSpaceId)}
hasMore={hasMore} hasMore={hasMore}
loadingMore={loadingMore} loadingMore={loadingMore}

View file

@ -2,6 +2,7 @@
import { FolderOpen, PenSquare } from "lucide-react"; import { FolderOpen, PenSquare } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -89,6 +90,7 @@ export function Sidebar({
isResizing = false, isResizing = false,
}: SidebarProps) { }: SidebarProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const [openDropdownChatId, setOpenDropdownChatId] = useState<number | null>(null);
return ( return (
<div <div
@ -103,6 +105,12 @@ export function Sidebar({
{/* Resize handle on right border */} {/* Resize handle on right border */}
{!isCollapsed && onResizeMouseDown && ( {!isCollapsed && onResizeMouseDown && (
<div <div
role="slider"
aria-label="Resize sidebar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={50}
tabIndex={0}
onMouseDown={onResizeMouseDown} onMouseDown={onResizeMouseDown}
className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-border active:bg-border z-10" className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-border active:bg-border z-10"
/> />
@ -215,6 +223,8 @@ export function Sidebar({
name={chat.name} name={chat.name}
isActive={chat.id === activeChatId} isActive={chat.id === activeChatId}
archived={chat.archived} archived={chat.archived}
dropdownOpen={openDropdownChatId === chat.id}
onDropdownOpenChange={(open) => setOpenDropdownChatId(open ? chat.id : null)}
onClick={() => onChatSelect(chat)} onClick={() => onChatSelect(chat)}
onRename={() => onChatRename?.(chat)} onRename={() => onChatRename?.(chat)}
onArchive={() => onChatArchive?.(chat)} onArchive={() => onChatArchive?.(chat)}
@ -287,6 +297,8 @@ export function Sidebar({
name={chat.name} name={chat.name}
isActive={chat.id === activeChatId} isActive={chat.id === activeChatId}
archived={chat.archived} archived={chat.archived}
dropdownOpen={openDropdownChatId === chat.id}
onDropdownOpenChange={(open) => setOpenDropdownChatId(open ? chat.id : null)}
onClick={() => onChatSelect(chat)} onClick={() => onChatSelect(chat)}
onRename={() => onChatRename?.(chat)} onRename={() => onChatRename?.(chat)}
onArchive={() => onChatArchive?.(chat)} onArchive={() => onChatArchive?.(chat)}

View file

@ -9,8 +9,8 @@ import {
Laptop, Laptop,
LogOut, LogOut,
Moon, Moon,
Settings,
Sun, Sun,
UserCog,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@ -206,7 +206,7 @@ export function SidebarUserProfile({
<DropdownMenuSeparator className="dark:bg-neutral-700" /> <DropdownMenuSeparator className="dark:bg-neutral-700" />
<DropdownMenuItem onClick={onUserSettings}> <DropdownMenuItem onClick={onUserSettings}>
<Settings className="h-4 w-4" /> <UserCog className="h-4 w-4" />
{t("user_settings")} {t("user_settings")}
</DropdownMenuItem> </DropdownMenuItem>
@ -351,7 +351,7 @@ export function SidebarUserProfile({
<DropdownMenuSeparator className="dark:bg-neutral-700" /> <DropdownMenuSeparator className="dark:bg-neutral-700" />
<DropdownMenuItem onClick={onUserSettings}> <DropdownMenuItem onClick={onUserSettings}>
<Settings className="h-4 w-4" /> <UserCog className="h-4 w-4" />
{t("user_settings")} {t("user_settings")}
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -5,17 +5,17 @@ import posthog from "posthog-js";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import "../../instrumentation-client"; import "../../instrumentation-client";
import { PostHogIdentify } from "./PostHogIdentify"; import { PostHogIdentify } from "./PostHogIdentify";
import { PostHogReferral } from "./PostHogReferral";
interface PostHogProviderProps { interface PostHogProviderProps {
children: ReactNode; children: ReactNode;
} }
export function PostHogProvider({ children }: PostHogProviderProps) { export function PostHogProvider({ children }: PostHogProviderProps) {
// posthog-js is initialized by importing instrumentation-client.ts above
// We wrap the app with the PostHogProvider for hook access
return ( return (
<PHProvider client={posthog}> <PHProvider client={posthog}>
<PostHogIdentify /> <PostHogIdentify />
<PostHogReferral />
{children} {children}
</PHProvider> </PHProvider>
); );

View file

@ -0,0 +1,34 @@
"use client";
import { useEffect } from "react";
import { trackReferralLanding } from "@/lib/posthog/events";
const REF_STORAGE_KEY = "surfsense_ref_code";
/**
* Captures the ?ref=<code> URL parameter on first landing and fires a
* PostHog event so marketing campaigns can be attributed.
*
* The ref code is persisted to sessionStorage so it survives client-side
* navigations that strip query params (e.g. login redirect), but a fresh
* event is fired for each new browser session with a ref param.
*/
export function PostHogReferral() {
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const ref = params.get("ref");
if (ref) {
try {
sessionStorage.setItem(REF_STORAGE_KEY, ref);
} catch {
// Private browsing may block sessionStorage
}
trackReferralLanding(ref, window.location.href);
}
}, []);
return null;
}

View file

@ -490,6 +490,12 @@ export function useDocuments(
apiToDisplayDoc, apiToDisplayDoc,
]); ]);
const removeItems = useCallback((ids: number[]) => {
const idSet = new Set(ids);
setDocuments((prev) => prev.filter((item) => !idSet.has(item.id)));
setTotal((prev) => Math.max(0, prev - ids.length));
}, []);
return { return {
documents, documents,
typeCounts, typeCounts,
@ -498,6 +504,7 @@ export function useDocuments(
loadingMore, loadingMore,
hasMore, hasMore,
loadMore, loadMore,
removeItems,
error, error,
}; };
} }

View file

@ -1,6 +1,7 @@
import posthog from "posthog-js"; import posthog from "posthog-js";
function initPostHog() { function initPostHog() {
try {
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return; if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return;
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
@ -10,7 +11,21 @@ function initPostHog() {
capture_pageview: "history_change", capture_pageview: "history_change",
capture_pageleave: true, capture_pageleave: true,
before_send: (event) => { before_send: (event) => {
if (event.properties) { if (event?.properties) {
const params = new URLSearchParams(window.location.search);
const ref = params.get("ref");
if (ref) {
event.properties.ref_code = ref;
event.properties.$set = {
...event.properties.$set,
initial_ref_code: ref,
};
event.properties.$set_once = {
...event.properties.$set_once,
first_ref_code: ref,
};
}
event.properties.$set = { event.properties.$set = {
...event.properties.$set, ...event.properties.$set,
last_seen_at: new Date().toISOString(), last_seen_at: new Date().toISOString(),
@ -24,6 +39,9 @@ function initPostHog() {
} }
}, },
}); });
} catch {
// PostHog init failed (likely ad-blocker) app must continue to work
}
} }
if (typeof window !== "undefined") { if (typeof window !== "undefined") {

View file

@ -3,7 +3,10 @@ import posthog from "posthog-js";
/** /**
* PostHog Analytics Event Definitions * PostHog Analytics Event Definitions
* *
* This file defines all custom analytics events tracked in SurfSense. * All capture/identify/reset calls are wrapped in try-catch so that
* ad-blockers that interfere with posthog-js can never break app
* functionality (e.g. the chat flow).
*
* Events follow a consistent naming convention: category_action * Events follow a consistent naming convention: category_action
* *
* Categories: * Categories:
@ -14,47 +17,47 @@ import posthog from "posthog-js";
* - connector: External connector events * - connector: External connector events
* - contact: Contact form events * - contact: Contact form events
* - settings: Settings changes * - settings: Settings changes
* - marketing: Marketing/referral tracking
*/ */
function safeCapture(event: string, properties?: Record<string, unknown>) {
try {
posthog.capture(event, properties);
} catch {
// Silently ignore analytics should never break the app
}
}
// ============================================ // ============================================
// AUTH EVENTS // AUTH EVENTS
// ============================================ // ============================================
export function trackLoginAttempt(method: "local" | "google") { export function trackLoginAttempt(method: "local" | "google") {
posthog.capture("auth_login_attempt", { safeCapture("auth_login_attempt", { method });
method,
});
} }
export function trackLoginSuccess(method: "local" | "google") { export function trackLoginSuccess(method: "local" | "google") {
posthog.capture("auth_login_success", { safeCapture("auth_login_success", { method });
method,
});
} }
export function trackLoginFailure(method: "local" | "google", error?: string) { export function trackLoginFailure(method: "local" | "google", error?: string) {
posthog.capture("auth_login_failure", { safeCapture("auth_login_failure", { method, error });
method,
error,
});
} }
export function trackRegistrationAttempt() { export function trackRegistrationAttempt() {
posthog.capture("auth_registration_attempt"); safeCapture("auth_registration_attempt");
} }
export function trackRegistrationSuccess() { export function trackRegistrationSuccess() {
posthog.capture("auth_registration_success"); safeCapture("auth_registration_success");
} }
export function trackRegistrationFailure(error?: string) { export function trackRegistrationFailure(error?: string) {
posthog.capture("auth_registration_failure", { safeCapture("auth_registration_failure", { error });
error,
});
} }
export function trackLogout() { export function trackLogout() {
posthog.capture("auth_logout"); safeCapture("auth_logout");
} }
// ============================================ // ============================================
@ -62,20 +65,20 @@ export function trackLogout() {
// ============================================ // ============================================
export function trackSearchSpaceCreated(searchSpaceId: number, name: string) { export function trackSearchSpaceCreated(searchSpaceId: number, name: string) {
posthog.capture("search_space_created", { safeCapture("search_space_created", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
name, name,
}); });
} }
export function trackSearchSpaceDeleted(searchSpaceId: number) { export function trackSearchSpaceDeleted(searchSpaceId: number) {
posthog.capture("search_space_deleted", { safeCapture("search_space_deleted", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
}); });
} }
export function trackSearchSpaceViewed(searchSpaceId: number) { export function trackSearchSpaceViewed(searchSpaceId: number) {
posthog.capture("search_space_viewed", { safeCapture("search_space_viewed", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
}); });
} }
@ -85,7 +88,7 @@ export function trackSearchSpaceViewed(searchSpaceId: number) {
// ============================================ // ============================================
export function trackChatCreated(searchSpaceId: number, chatId: number) { export function trackChatCreated(searchSpaceId: number, chatId: number) {
posthog.capture("chat_created", { safeCapture("chat_created", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
chat_id: chatId, chat_id: chatId,
}); });
@ -100,7 +103,7 @@ export function trackChatMessageSent(
messageLength?: number; messageLength?: number;
} }
) { ) {
posthog.capture("chat_message_sent", { safeCapture("chat_message_sent", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
chat_id: chatId, chat_id: chatId,
has_attachments: options?.hasAttachments ?? false, has_attachments: options?.hasAttachments ?? false,
@ -110,14 +113,14 @@ export function trackChatMessageSent(
} }
export function trackChatResponseReceived(searchSpaceId: number, chatId: number) { export function trackChatResponseReceived(searchSpaceId: number, chatId: number) {
posthog.capture("chat_response_received", { safeCapture("chat_response_received", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
chat_id: chatId, chat_id: chatId,
}); });
} }
export function trackChatError(searchSpaceId: number, chatId: number, error?: string) { export function trackChatError(searchSpaceId: number, chatId: number, error?: string) {
posthog.capture("chat_error", { safeCapture("chat_error", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
chat_id: chatId, chat_id: chatId,
error, error,
@ -133,7 +136,7 @@ export function trackDocumentUploadStarted(
fileCount: number, fileCount: number,
totalSizeBytes: number totalSizeBytes: number
) { ) {
posthog.capture("document_upload_started", { safeCapture("document_upload_started", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
file_count: fileCount, file_count: fileCount,
total_size_bytes: totalSizeBytes, total_size_bytes: totalSizeBytes,
@ -141,35 +144,35 @@ export function trackDocumentUploadStarted(
} }
export function trackDocumentUploadSuccess(searchSpaceId: number, fileCount: number) { export function trackDocumentUploadSuccess(searchSpaceId: number, fileCount: number) {
posthog.capture("document_upload_success", { safeCapture("document_upload_success", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
file_count: fileCount, file_count: fileCount,
}); });
} }
export function trackDocumentUploadFailure(searchSpaceId: number, error?: string) { export function trackDocumentUploadFailure(searchSpaceId: number, error?: string) {
posthog.capture("document_upload_failure", { safeCapture("document_upload_failure", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
error, error,
}); });
} }
export function trackDocumentDeleted(searchSpaceId: number, documentId: number) { export function trackDocumentDeleted(searchSpaceId: number, documentId: number) {
posthog.capture("document_deleted", { safeCapture("document_deleted", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
document_id: documentId, document_id: documentId,
}); });
} }
export function trackDocumentBulkDeleted(searchSpaceId: number, count: number) { export function trackDocumentBulkDeleted(searchSpaceId: number, count: number) {
posthog.capture("document_bulk_deleted", { safeCapture("document_bulk_deleted", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
count, count,
}); });
} }
export function trackYouTubeImport(searchSpaceId: number, url: string) { export function trackYouTubeImport(searchSpaceId: number, url: string) {
posthog.capture("youtube_import_started", { safeCapture("youtube_import_started", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
url, url,
}); });
@ -180,7 +183,7 @@ export function trackYouTubeImport(searchSpaceId: number, url: string) {
// ============================================ // ============================================
export function trackConnectorSetupStarted(searchSpaceId: number, connectorType: string) { export function trackConnectorSetupStarted(searchSpaceId: number, connectorType: string) {
posthog.capture("connector_setup_started", { safeCapture("connector_setup_started", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
connector_type: connectorType, connector_type: connectorType,
}); });
@ -191,7 +194,7 @@ export function trackConnectorSetupSuccess(
connectorType: string, connectorType: string,
connectorId: number connectorId: number
) { ) {
posthog.capture("connector_setup_success", { safeCapture("connector_setup_success", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
connector_type: connectorType, connector_type: connectorType,
connector_id: connectorId, connector_id: connectorId,
@ -203,7 +206,7 @@ export function trackConnectorSetupFailure(
connectorType: string, connectorType: string,
error?: string error?: string
) { ) {
posthog.capture("connector_setup_failure", { safeCapture("connector_setup_failure", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
connector_type: connectorType, connector_type: connectorType,
error, error,
@ -215,7 +218,7 @@ export function trackConnectorDeleted(
connectorType: string, connectorType: string,
connectorId: number connectorId: number
) { ) {
posthog.capture("connector_deleted", { safeCapture("connector_deleted", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
connector_type: connectorType, connector_type: connectorType,
connector_id: connectorId, connector_id: connectorId,
@ -227,7 +230,7 @@ export function trackConnectorSynced(
connectorType: string, connectorType: string,
connectorId: number connectorId: number
) { ) {
posthog.capture("connector_synced", { safeCapture("connector_synced", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
connector_type: connectorType, connector_type: connectorType,
connector_id: connectorId, connector_id: connectorId,
@ -239,14 +242,14 @@ export function trackConnectorSynced(
// ============================================ // ============================================
export function trackSettingsViewed(searchSpaceId: number, section: string) { export function trackSettingsViewed(searchSpaceId: number, section: string) {
posthog.capture("settings_viewed", { safeCapture("settings_viewed", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
section, section,
}); });
} }
export function trackSettingsUpdated(searchSpaceId: number, section: string, setting: string) { export function trackSettingsUpdated(searchSpaceId: number, section: string, setting: string) {
posthog.capture("settings_updated", { safeCapture("settings_updated", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
section, section,
setting, setting,
@ -258,14 +261,14 @@ export function trackSettingsUpdated(searchSpaceId: number, section: string, set
// ============================================ // ============================================
export function trackPodcastGenerated(searchSpaceId: number, chatId: number) { export function trackPodcastGenerated(searchSpaceId: number, chatId: number) {
posthog.capture("podcast_generated", { safeCapture("podcast_generated", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
chat_id: chatId, chat_id: chatId,
}); });
} }
export function trackSourcesTabViewed(searchSpaceId: number, tab: string) { export function trackSourcesTabViewed(searchSpaceId: number, tab: string) {
posthog.capture("sources_tab_viewed", { safeCapture("sources_tab_viewed", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
tab, tab,
}); });
@ -283,7 +286,7 @@ export function trackSearchSpaceInviteSent(
hasMaxUses?: boolean; hasMaxUses?: boolean;
} }
) { ) {
posthog.capture("search_space_invite_sent", { safeCapture("search_space_invite_sent", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
role_name: options?.roleName, role_name: options?.roleName,
has_expiry: options?.hasExpiry ?? false, has_expiry: options?.hasExpiry ?? false,
@ -296,7 +299,7 @@ export function trackSearchSpaceInviteAccepted(
searchSpaceName: string, searchSpaceName: string,
roleName?: string | null roleName?: string | null
) { ) {
posthog.capture("search_space_invite_accepted", { safeCapture("search_space_invite_accepted", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
search_space_name: searchSpaceName, search_space_name: searchSpaceName,
role_name: roleName, role_name: roleName,
@ -304,7 +307,7 @@ export function trackSearchSpaceInviteAccepted(
} }
export function trackSearchSpaceInviteDeclined(searchSpaceName?: string) { export function trackSearchSpaceInviteDeclined(searchSpaceName?: string) {
posthog.capture("search_space_invite_declined", { safeCapture("search_space_invite_declined", {
search_space_name: searchSpaceName, search_space_name: searchSpaceName,
}); });
} }
@ -314,7 +317,7 @@ export function trackSearchSpaceUserAdded(
searchSpaceName: string, searchSpaceName: string,
roleName?: string | null roleName?: string | null
) { ) {
posthog.capture("search_space_user_added", { safeCapture("search_space_user_added", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
search_space_name: searchSpaceName, search_space_name: searchSpaceName,
role_name: roleName, role_name: roleName,
@ -326,7 +329,7 @@ export function trackSearchSpaceUsersViewed(
userCount: number, userCount: number,
ownerCount: number ownerCount: number
) { ) {
posthog.capture("search_space_users_viewed", { safeCapture("search_space_users_viewed", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
user_count: userCount, user_count: userCount,
owner_count: ownerCount, owner_count: ownerCount,
@ -342,7 +345,7 @@ export function trackConnectorConnected(
connectorType: string, connectorType: string,
connectorId?: number connectorId?: number
) { ) {
posthog.capture("connector_connected", { safeCapture("connector_connected", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
connector_type: connectorType, connector_type: connectorType,
connector_id: connectorId, connector_id: connectorId,
@ -358,7 +361,7 @@ export function trackIndexWithDateRangeOpened(
connectorType: string, connectorType: string,
connectorId: number connectorId: number
) { ) {
posthog.capture("index_with_date_range_opened", { safeCapture("index_with_date_range_opened", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
connector_type: connectorType, connector_type: connectorType,
connector_id: connectorId, connector_id: connectorId,
@ -374,7 +377,7 @@ export function trackIndexWithDateRangeStarted(
hasEndDate?: boolean; hasEndDate?: boolean;
} }
) { ) {
posthog.capture("index_with_date_range_started", { safeCapture("index_with_date_range_started", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
connector_type: connectorType, connector_type: connectorType,
connector_id: connectorId, connector_id: connectorId,
@ -388,7 +391,7 @@ export function trackQuickIndexClicked(
connectorType: string, connectorType: string,
connectorId: number connectorId: number
) { ) {
posthog.capture("quick_index_clicked", { safeCapture("quick_index_clicked", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
connector_type: connectorType, connector_type: connectorType,
connector_id: connectorId, connector_id: connectorId,
@ -400,7 +403,7 @@ export function trackConfigurePeriodicIndexingOpened(
connectorType: string, connectorType: string,
connectorId: number connectorId: number
) { ) {
posthog.capture("configure_periodic_indexing_opened", { safeCapture("configure_periodic_indexing_opened", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
connector_type: connectorType, connector_type: connectorType,
connector_id: connectorId, connector_id: connectorId,
@ -413,7 +416,7 @@ export function trackPeriodicIndexingStarted(
connectorId: number, connectorId: number,
frequencyMinutes: number frequencyMinutes: number
) { ) {
posthog.capture("periodic_indexing_started", { safeCapture("periodic_indexing_started", {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
connector_type: connectorType, connector_type: connectorType,
connector_id: connectorId, connector_id: connectorId,
@ -426,24 +429,37 @@ export function trackPeriodicIndexingStarted(
// ============================================ // ============================================
export function trackIncentivePageViewed() { export function trackIncentivePageViewed() {
posthog.capture("incentive_page_viewed"); safeCapture("incentive_page_viewed");
} }
export function trackIncentiveTaskCompleted(taskType: string, pagesRewarded: number) { export function trackIncentiveTaskCompleted(taskType: string, pagesRewarded: number) {
posthog.capture("incentive_task_completed", { safeCapture("incentive_task_completed", {
task_type: taskType, task_type: taskType,
pages_rewarded: pagesRewarded, pages_rewarded: pagesRewarded,
}); });
} }
export function trackIncentiveTaskClicked(taskType: string) { export function trackIncentiveTaskClicked(taskType: string) {
posthog.capture("incentive_task_clicked", { safeCapture("incentive_task_clicked", {
task_type: taskType, task_type: taskType,
}); });
} }
export function trackIncentiveContactOpened() { export function trackIncentiveContactOpened() {
posthog.capture("incentive_contact_opened"); safeCapture("incentive_contact_opened");
}
// ============================================
// MARKETING / REFERRAL EVENTS
// ============================================
export function trackReferralLanding(refCode: string, landingUrl: string) {
safeCapture("marketing_referral_landing", {
ref_code: refCode,
landing_url: landingUrl,
$set_once: { first_ref_code: refCode },
$set: { latest_ref_code: refCode },
});
} }
// ============================================ // ============================================
@ -455,12 +471,20 @@ export function trackIncentiveContactOpened() {
* Call this after successful authentication * Call this after successful authentication
*/ */
export function identifyUser(userId: string, properties?: Record<string, unknown>) { export function identifyUser(userId: string, properties?: Record<string, unknown>) {
try {
posthog.identify(userId, properties); posthog.identify(userId, properties);
} catch {
// Silently ignore ad-blockers may break posthog
}
} }
/** /**
* Reset user identity (call on logout) * Reset user identity (call on logout)
*/ */
export function resetUser() { export function resetUser() {
try {
posthog.reset(); posthog.reset();
} catch {
// Silently ignore ad-blockers may break posthog
}
} }