mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-09 15:52:40 +02:00
commit
52248ac74a
21 changed files with 647 additions and 240 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<span className="text-xs font-medium text-muted-foreground">Status</span>
|
{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>
|
||||||
|
)}
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -235,39 +235,39 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isEditMode ? (
|
{isEditMode ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsFolderTreeOpen(!isFolderTreeOpen)}
|
onClick={() => setIsFolderTreeOpen(!isFolderTreeOpen)}
|
||||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
|
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||||
>
|
>
|
||||||
{isFolderTreeOpen ? (
|
{isFolderTreeOpen ? (
|
||||||
<ChevronDown className="size-4" />
|
<ChevronDown className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
|
)}
|
||||||
|
Change Selection
|
||||||
|
</button>
|
||||||
|
{isFolderTreeOpen && (
|
||||||
|
<ComposioDriveFolderTree
|
||||||
|
connectorId={connector.id}
|
||||||
|
selectedFolders={selectedFolders}
|
||||||
|
onSelectFolders={handleSelectFolders}
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
onSelectFiles={handleSelectFiles}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
Change Selection
|
</div>
|
||||||
</button>
|
) : (
|
||||||
{isFolderTreeOpen && (
|
<ComposioDriveFolderTree
|
||||||
<ComposioDriveFolderTree
|
connectorId={connector.id}
|
||||||
connectorId={connector.id}
|
selectedFolders={selectedFolders}
|
||||||
selectedFolders={selectedFolders}
|
onSelectFolders={handleSelectFolders}
|
||||||
onSelectFolders={handleSelectFolders}
|
selectedFiles={selectedFiles}
|
||||||
selectedFiles={selectedFiles}
|
onSelectFiles={handleSelectFiles}
|
||||||
onSelectFiles={handleSelectFiles}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ComposioDriveFolderTree
|
|
||||||
connectorId={connector.id}
|
|
||||||
selectedFolders={selectedFolders}
|
|
||||||
onSelectFolders={handleSelectFolders}
|
|
||||||
selectedFiles={selectedFiles}
|
|
||||||
onSelectFiles={handleSelectFiles}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Indexing Options */}
|
{/* Indexing Options */}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
const row = (
|
||||||
|
<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">
|
||||||
|
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
|
||||||
|
{formatToolName(tool.name)}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={!isDisabled}
|
||||||
|
onCheckedChange={() => toggleTool(tool.name)}
|
||||||
|
className="shrink-0 scale-[0.6] sm:scale-75"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
if (!isDesktop) {
|
||||||
|
return <div key={tool.name}>{row}</div>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Tooltip key={tool.name}>
|
<Tooltip key={tool.name}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>{row}</TooltipTrigger>
|
||||||
<label className="flex items-center gap-3 px-3 py-1.5 cursor-pointer hover:bg-muted-foreground/10 transition-colors">
|
|
||||||
<span className="flex-1 min-w-0 text-sm font-medium truncate">{formatToolName(tool.name)}</span>
|
|
||||||
<Switch
|
|
||||||
checked={!isDisabled}
|
|
||||||
onCheckedChange={() => toggleTool(tool.name)}
|
|
||||||
className="shrink-0 scale-75"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
if (isCurrentSpace) {
|
||||||
|
if (updatedSpaces.length > 0) {
|
||||||
|
router.push(`/dashboard/${updatedSpaces[0].id}/new-chat`);
|
||||||
|
} else {
|
||||||
|
router.push("/dashboard");
|
||||||
}
|
}
|
||||||
} else if (searchSpaces.length === 1) {
|
|
||||||
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);
|
||||||
|
|
||||||
|
if (isCurrentSpace) {
|
||||||
|
if (updatedSpaces.length > 0) {
|
||||||
|
router.push(`/dashboard/${updatedSpaces[0].id}/new-chat`);
|
||||||
|
} else {
|
||||||
|
router.push("/dashboard");
|
||||||
}
|
}
|
||||||
} else if (searchSpaces.length === 1) {
|
|
||||||
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) => {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
<AvatarFallback className="bg-muted text-[10px]">
|
key={connector.id}
|
||||||
{getConnectorIcon(type, "size-3.5")}
|
className="size-6"
|
||||||
</AvatarFallback>
|
style={{ zIndex: Math.max(9 - i, 1) }}
|
||||||
</Avatar>
|
>
|
||||||
</TooltipTrigger>
|
<AvatarFallback className="bg-muted text-[10px]">
|
||||||
<TooltipContent side="top" className="text-xs">
|
{getConnectorIcon(connector.connector_type, "size-3.5")}
|
||||||
{label}
|
</AvatarFallback>
|
||||||
</TooltipContent>
|
</Avatar>
|
||||||
</Tooltip>
|
);
|
||||||
))}
|
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]">
|
||||||
|
{getConnectorIcon(type, "size-3.5")}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
if (isMobile) return avatar;
|
||||||
|
return (
|
||||||
|
<Tooltip key={type}>
|
||||||
|
<TooltipTrigger asChild>{avatar}</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="text-xs">
|
||||||
|
{label}
|
||||||
|
</TooltipContent>
|
||||||
|
</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}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
34
surfsense_web/components/providers/PostHogReferral.tsx
Normal file
34
surfsense_web/components/providers/PostHogReferral.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,17 +154,17 @@ export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOption
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === google.picker.Action.ERROR) {
|
if (action === google.picker.Action.ERROR) {
|
||||||
setError("Google Drive encountered an error. Please try again.");
|
setError("Google Drive encountered an error. Please try again.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
action === google.picker.Action.PICKED ||
|
action === google.picker.Action.PICKED ||
|
||||||
action === google.picker.Action.CANCEL ||
|
action === google.picker.Action.CANCEL ||
|
||||||
action === google.picker.Action.ERROR
|
action === google.picker.Action.ERROR
|
||||||
) {
|
) {
|
||||||
closePicker();
|
closePicker();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,47 @@
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
|
|
||||||
function initPostHog() {
|
function initPostHog() {
|
||||||
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return;
|
try {
|
||||||
|
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return;
|
||||||
|
|
||||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||||
api_host: "/ingest",
|
api_host: "/ingest",
|
||||||
ui_host: "https://us.posthog.com",
|
ui_host: "https://us.posthog.com",
|
||||||
defaults: "2025-11-30",
|
defaults: "2025-11-30",
|
||||||
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) {
|
||||||
event.properties.$set = {
|
const params = new URLSearchParams(window.location.search);
|
||||||
...event.properties.$set,
|
const ref = params.get("ref");
|
||||||
last_seen_at: new Date().toISOString(),
|
if (ref) {
|
||||||
};
|
event.properties.ref_code = ref;
|
||||||
}
|
event.properties.$set = {
|
||||||
return event;
|
...event.properties.$set,
|
||||||
},
|
initial_ref_code: ref,
|
||||||
loaded: (ph) => {
|
};
|
||||||
if (typeof window !== "undefined") {
|
event.properties.$set_once = {
|
||||||
window.posthog = ph;
|
...event.properties.$set_once,
|
||||||
}
|
first_ref_code: ref,
|
||||||
},
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
|
event.properties.$set = {
|
||||||
|
...event.properties.$set,
|
||||||
|
last_seen_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
loaded: (ph) => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.posthog = ph;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// PostHog init failed (likely ad-blocker) – app must continue to work
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
|
|
|
||||||
|
|
@ -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>) {
|
||||||
posthog.identify(userId, properties);
|
try {
|
||||||
|
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() {
|
||||||
posthog.reset();
|
try {
|
||||||
|
posthog.reset();
|
||||||
|
} catch {
|
||||||
|
// Silently ignore – ad-blockers may break posthog
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue