mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-10 16:22:38 +02:00
Merge remote-tracking branch 'upstream/dev' into fix/github-and-ui-fixes
This commit is contained in:
commit
701bb7063f
89 changed files with 3627 additions and 3951 deletions
|
|
@ -320,6 +320,8 @@ async def read_documents(
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
search_space_id: int | None = None,
|
search_space_id: int | None = None,
|
||||||
document_types: str | None = None,
|
document_types: str | None = None,
|
||||||
|
sort_by: str = "created_at",
|
||||||
|
sort_order: str = "desc",
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
|
@ -392,6 +394,19 @@ async def read_documents(
|
||||||
total_result = await session.execute(count_query)
|
total_result = await session.execute(count_query)
|
||||||
total = total_result.scalar() or 0
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# Apply sorting
|
||||||
|
from sqlalchemy import asc as sa_asc, desc as sa_desc
|
||||||
|
|
||||||
|
sort_column_map = {
|
||||||
|
"created_at": Document.created_at,
|
||||||
|
"title": Document.title,
|
||||||
|
"document_type": Document.document_type,
|
||||||
|
}
|
||||||
|
sort_col = sort_column_map.get(sort_by, Document.created_at)
|
||||||
|
query = query.order_by(
|
||||||
|
sa_desc(sort_col) if sort_order == "desc" else sa_asc(sort_col)
|
||||||
|
)
|
||||||
|
|
||||||
# Calculate offset
|
# Calculate offset
|
||||||
offset = 0
|
offset = 0
|
||||||
if skip is not None:
|
if skip is not None:
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import desc, func, select, update
|
from sqlalchemy import desc, func, literal, literal_column, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db import Notification, User, get_async_session
|
from app.db import Notification, User, get_async_session
|
||||||
|
|
@ -23,9 +23,26 @@ SYNC_WINDOW_DAYS = 14
|
||||||
|
|
||||||
# Valid notification types - must match frontend InboxItemTypeEnum
|
# Valid notification types - must match frontend InboxItemTypeEnum
|
||||||
NotificationType = Literal[
|
NotificationType = Literal[
|
||||||
"connector_indexing", "document_processing", "new_mention", "page_limit_exceeded"
|
"connector_indexing",
|
||||||
|
"connector_deletion",
|
||||||
|
"document_processing",
|
||||||
|
"new_mention",
|
||||||
|
"comment_reply",
|
||||||
|
"page_limit_exceeded",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Category-to-types mapping for filtering by tab
|
||||||
|
NotificationCategory = Literal["comments", "status"]
|
||||||
|
CATEGORY_TYPES: dict[str, tuple[str, ...]] = {
|
||||||
|
"comments": ("new_mention", "comment_reply"),
|
||||||
|
"status": (
|
||||||
|
"connector_indexing",
|
||||||
|
"connector_deletion",
|
||||||
|
"document_processing",
|
||||||
|
"page_limit_exceeded",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class NotificationResponse(BaseModel):
|
class NotificationResponse(BaseModel):
|
||||||
"""Response model for a single notification."""
|
"""Response model for a single notification."""
|
||||||
|
|
@ -69,6 +86,21 @@ class MarkAllReadResponse(BaseModel):
|
||||||
updated_count: int
|
updated_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class SourceTypeItem(BaseModel):
|
||||||
|
"""A single source type with its category and count."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
type: str
|
||||||
|
category: str # "connector" or "document"
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class SourceTypesResponse(BaseModel):
|
||||||
|
"""Response for notification source types used in status tab filter."""
|
||||||
|
|
||||||
|
sources: list[SourceTypeItem]
|
||||||
|
|
||||||
|
|
||||||
class UnreadCountResponse(BaseModel):
|
class UnreadCountResponse(BaseModel):
|
||||||
"""Response for unread count with split between recent and older items."""
|
"""Response for unread count with split between recent and older items."""
|
||||||
|
|
||||||
|
|
@ -76,12 +108,86 @@ class UnreadCountResponse(BaseModel):
|
||||||
recent_unread: int # Within SYNC_WINDOW_DAYS
|
recent_unread: int # Within SYNC_WINDOW_DAYS
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/source-types", response_model=SourceTypesResponse)
|
||||||
|
async def get_notification_source_types(
|
||||||
|
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> SourceTypesResponse:
|
||||||
|
"""
|
||||||
|
Get all distinct connector types and document types from the user's
|
||||||
|
status notifications. Used to populate the filter dropdown in the
|
||||||
|
inbox Status tab so that all types are shown regardless of pagination.
|
||||||
|
"""
|
||||||
|
base_filter = [Notification.user_id == user.id]
|
||||||
|
|
||||||
|
if search_space_id is not None:
|
||||||
|
base_filter.append(
|
||||||
|
(Notification.search_space_id == search_space_id)
|
||||||
|
| (Notification.search_space_id.is_(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
connector_type_expr = Notification.notification_metadata["connector_type"].astext
|
||||||
|
connector_query = (
|
||||||
|
select(
|
||||||
|
connector_type_expr.label("source_type"),
|
||||||
|
literal("connector").label("category"),
|
||||||
|
func.count(Notification.id).label("cnt"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
*base_filter,
|
||||||
|
Notification.type.in_(("connector_indexing", "connector_deletion")),
|
||||||
|
connector_type_expr.isnot(None),
|
||||||
|
)
|
||||||
|
.group_by(literal_column("source_type"))
|
||||||
|
)
|
||||||
|
|
||||||
|
document_type_expr = Notification.notification_metadata["document_type"].astext
|
||||||
|
document_query = (
|
||||||
|
select(
|
||||||
|
document_type_expr.label("source_type"),
|
||||||
|
literal("document").label("category"),
|
||||||
|
func.count(Notification.id).label("cnt"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
*base_filter,
|
||||||
|
Notification.type.in_(("document_processing",)),
|
||||||
|
document_type_expr.isnot(None),
|
||||||
|
)
|
||||||
|
.group_by(literal_column("source_type"))
|
||||||
|
)
|
||||||
|
|
||||||
|
connector_result = await session.execute(connector_query)
|
||||||
|
document_result = await session.execute(document_query)
|
||||||
|
|
||||||
|
sources = []
|
||||||
|
for source_type, category, count in [
|
||||||
|
*connector_result.all(),
|
||||||
|
*document_result.all(),
|
||||||
|
]:
|
||||||
|
if not source_type:
|
||||||
|
continue
|
||||||
|
sources.append(
|
||||||
|
SourceTypeItem(
|
||||||
|
key=f"{category}:{source_type}",
|
||||||
|
type=source_type,
|
||||||
|
category=category,
|
||||||
|
count=count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SourceTypesResponse(sources=sources)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||||
async def get_unread_count(
|
async def get_unread_count(
|
||||||
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
||||||
type_filter: NotificationType | None = Query(
|
type_filter: NotificationType | None = Query(
|
||||||
None, alias="type", description="Filter by notification type"
|
None, alias="type", description="Filter by notification type"
|
||||||
),
|
),
|
||||||
|
category: NotificationCategory | None = Query(
|
||||||
|
None, description="Filter by category: 'comments' or 'status'"
|
||||||
|
),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> UnreadCountResponse:
|
) -> UnreadCountResponse:
|
||||||
|
|
@ -116,6 +222,10 @@ async def get_unread_count(
|
||||||
if type_filter:
|
if type_filter:
|
||||||
base_filter.append(Notification.type == type_filter)
|
base_filter.append(Notification.type == type_filter)
|
||||||
|
|
||||||
|
# Filter by category (maps to multiple types)
|
||||||
|
if category:
|
||||||
|
base_filter.append(Notification.type.in_(CATEGORY_TYPES[category]))
|
||||||
|
|
||||||
# Total unread count (all time)
|
# Total unread count (all time)
|
||||||
total_query = select(func.count(Notification.id)).where(*base_filter)
|
total_query = select(func.count(Notification.id)).where(*base_filter)
|
||||||
total_result = await session.execute(total_query)
|
total_result = await session.execute(total_query)
|
||||||
|
|
@ -141,6 +251,17 @@ async def list_notifications(
|
||||||
type_filter: NotificationType | None = Query(
|
type_filter: NotificationType | None = Query(
|
||||||
None, alias="type", description="Filter by notification type"
|
None, alias="type", description="Filter by notification type"
|
||||||
),
|
),
|
||||||
|
category: NotificationCategory | None = Query(
|
||||||
|
None, description="Filter by category: 'comments' or 'status'"
|
||||||
|
),
|
||||||
|
source_type: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="Filter by source type, e.g. 'connector:GITHUB_CONNECTOR' or 'doctype:FILE'",
|
||||||
|
),
|
||||||
|
filter: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="Filter preset: 'unread' for unread only, 'errors' for failed/error items only",
|
||||||
|
),
|
||||||
before_date: str | None = Query(
|
before_date: str | None = Query(
|
||||||
None, description="Get notifications before this ISO date (for pagination)"
|
None, description="Get notifications before this ISO date (for pagination)"
|
||||||
),
|
),
|
||||||
|
|
@ -182,6 +303,45 @@ async def list_notifications(
|
||||||
query = query.where(Notification.type == type_filter)
|
query = query.where(Notification.type == type_filter)
|
||||||
count_query = count_query.where(Notification.type == type_filter)
|
count_query = count_query.where(Notification.type == type_filter)
|
||||||
|
|
||||||
|
# Filter by category (maps to multiple types)
|
||||||
|
if category:
|
||||||
|
cat_types = CATEGORY_TYPES[category]
|
||||||
|
query = query.where(Notification.type.in_(cat_types))
|
||||||
|
count_query = count_query.where(Notification.type.in_(cat_types))
|
||||||
|
|
||||||
|
# Filter by source type (connector or document type from JSONB metadata)
|
||||||
|
if source_type:
|
||||||
|
if source_type.startswith("connector:"):
|
||||||
|
connector_val = source_type[len("connector:") :]
|
||||||
|
source_filter = Notification.type.in_(
|
||||||
|
("connector_indexing", "connector_deletion")
|
||||||
|
) & (
|
||||||
|
Notification.notification_metadata["connector_type"].astext
|
||||||
|
== connector_val
|
||||||
|
)
|
||||||
|
query = query.where(source_filter)
|
||||||
|
count_query = count_query.where(source_filter)
|
||||||
|
elif source_type.startswith("doctype:"):
|
||||||
|
doctype_val = source_type[len("doctype:") :]
|
||||||
|
source_filter = Notification.type.in_(("document_processing",)) & (
|
||||||
|
Notification.notification_metadata["document_type"].astext
|
||||||
|
== doctype_val
|
||||||
|
)
|
||||||
|
query = query.where(source_filter)
|
||||||
|
count_query = count_query.where(source_filter)
|
||||||
|
|
||||||
|
# Filter by preset: 'unread' or 'errors'
|
||||||
|
if filter == "unread":
|
||||||
|
unread_filter = Notification.read == False # noqa: E712
|
||||||
|
query = query.where(unread_filter)
|
||||||
|
count_query = count_query.where(unread_filter)
|
||||||
|
elif filter == "errors":
|
||||||
|
error_filter = (Notification.type == "page_limit_exceeded") | (
|
||||||
|
Notification.notification_metadata["status"].astext == "failed"
|
||||||
|
)
|
||||||
|
query = query.where(error_filter)
|
||||||
|
count_query = count_query.where(error_filter)
|
||||||
|
|
||||||
# Filter by date (for efficient pagination of older items)
|
# Filter by date (for efficient pagination of older items)
|
||||||
if before_date:
|
if before_date:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@ import {
|
||||||
llmPreferencesAtom,
|
llmPreferencesAtom,
|
||||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
|
||||||
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
|
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
|
||||||
import { LayoutDataProvider } from "@/components/layout";
|
import { LayoutDataProvider } from "@/components/layout";
|
||||||
import { OnboardingTour } from "@/components/onboarding-tour";
|
import { OnboardingTour } from "@/components/onboarding-tour";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
@ -27,8 +25,6 @@ export function DashboardClientLayout({
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
navSecondary?: any[];
|
|
||||||
navMain?: any[];
|
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("dashboard");
|
const t = useTranslations("dashboard");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -190,11 +186,7 @@ export function DashboardClientLayout({
|
||||||
return (
|
return (
|
||||||
<DocumentUploadDialogProvider>
|
<DocumentUploadDialogProvider>
|
||||||
<OnboardingTour />
|
<OnboardingTour />
|
||||||
<LayoutDataProvider searchSpaceId={searchSpaceId} breadcrumb={<DashboardBreadcrumb />}>
|
<LayoutDataProvider searchSpaceId={searchSpaceId}>{children}</LayoutDataProvider>
|
||||||
{children}
|
|
||||||
</LayoutDataProvider>
|
|
||||||
{/* Global connector dialog - triggered from documents page */}
|
|
||||||
<ConnectorIndicator hideTrigger />
|
|
||||||
</DocumentUploadDialogProvider>
|
</DocumentUploadDialogProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSetAtom } from "jotai";
|
import { ListFilter, Search, Upload, X } from "lucide-react";
|
||||||
import {
|
|
||||||
CircleAlert,
|
|
||||||
FileType,
|
|
||||||
ListFilter,
|
|
||||||
Search,
|
|
||||||
SlidersHorizontal,
|
|
||||||
Trash,
|
|
||||||
Upload,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import React, { useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
|
||||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -36,18 +13,14 @@ import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon";
|
||||||
|
|
||||||
export function DocumentsFilters({
|
export function DocumentsFilters({
|
||||||
typeCounts: typeCountsRecord,
|
typeCounts: typeCountsRecord,
|
||||||
selectedIds,
|
|
||||||
onSearch,
|
onSearch,
|
||||||
searchValue,
|
searchValue,
|
||||||
onBulkDelete,
|
|
||||||
onToggleType,
|
onToggleType,
|
||||||
activeTypes,
|
activeTypes,
|
||||||
}: {
|
}: {
|
||||||
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
|
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
|
||||||
selectedIds: Set<number>;
|
|
||||||
onSearch: (v: string) => void;
|
onSearch: (v: string) => void;
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
onBulkDelete: () => Promise<void>;
|
|
||||||
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
|
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
|
||||||
activeTypes: DocumentTypeEnum[];
|
activeTypes: DocumentTypeEnum[];
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -55,11 +28,16 @@ export function DocumentsFilters({
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Dialog hooks for action buttons
|
|
||||||
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
|
||||||
|
|
||||||
const [typeSearchQuery, setTypeSearchQuery] = useState("");
|
const [typeSearchQuery, setTypeSearchQuery] = useState("");
|
||||||
|
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atTop = el.scrollTop <= 2;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||||
|
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const uniqueTypes = useMemo(() => {
|
const uniqueTypes = useMemo(() => {
|
||||||
return Object.keys(typeCountsRecord).sort() as DocumentTypeEnum[];
|
return Object.keys(typeCountsRecord).sort() as DocumentTypeEnum[];
|
||||||
|
|
@ -80,235 +58,145 @@ export function DocumentsFilters({
|
||||||
}, [typeCountsRecord]);
|
}, [typeCountsRecord]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="flex select-none">
|
||||||
className="flex flex-col gap-4 select-none"
|
<div className="flex items-center gap-2 w-full">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
{/* Type Filter */}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<Popover>
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.1 }}
|
<PopoverTrigger asChild>
|
||||||
>
|
<Button
|
||||||
{/* Main toolbar row */}
|
variant="outline"
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
size="icon"
|
||||||
{/* Action Buttons - Left Side */}
|
className="h-9 w-9 shrink-0 border-dashed border-sidebar-border text-sidebar-foreground/60 hover:text-sidebar-foreground hover:border-sidebar-border bg-sidebar"
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<Button
|
<ListFilter size={14} />
|
||||||
onClick={openUploadDialog}
|
{activeTypes.length > 0 && (
|
||||||
variant="outline"
|
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[9px] font-medium text-primary-foreground">
|
||||||
size="sm"
|
{activeTypes.length}
|
||||||
className="h-9 gap-2 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
|
</span>
|
||||||
>
|
)}
|
||||||
<Upload size={16} />
|
</Button>
|
||||||
<span>Upload documents</span>
|
</PopoverTrigger>
|
||||||
</Button>
|
<PopoverContent className="w-64 !p-0 overflow-hidden" align="end">
|
||||||
<Button
|
<div>
|
||||||
onClick={() => setConnectorDialogOpen(true)}
|
{/* Search input */}
|
||||||
variant="outline"
|
<div className="p-2 border-b border-neutral-700">
|
||||||
size="sm"
|
<div className="relative">
|
||||||
className="h-9 gap-2 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
|
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
>
|
<Input
|
||||||
<SlidersHorizontal size={16} />
|
placeholder="Search types"
|
||||||
<span>Manage connectors</span>
|
value={typeSearchQuery}
|
||||||
</Button>
|
onChange={(e) => setTypeSearchQuery(e.target.value)}
|
||||||
</div>
|
className="h-6 pl-6 text-sm bg-transparent border-0 shadow-none focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Spacer */}
|
<div
|
||||||
<div className="flex-1" />
|
className="max-h-[300px] overflow-y-auto overflow-x-hidden py-1.5 px-1.5"
|
||||||
|
onScroll={handleScroll}
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredTypes.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No types found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredTypes.map((value: DocumentTypeEnum, i) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={value}
|
||||||
|
className="flex w-full items-center gap-2.5 py-2 px-3 rounded-md hover:bg-muted/50 transition-colors cursor-pointer text-left"
|
||||||
|
onClick={() => onToggleType(value, !activeTypes.includes(value))}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted/50 text-foreground/80">
|
||||||
|
{getDocumentTypeIcon(value, "h-4 w-4")}
|
||||||
|
</div>
|
||||||
|
{/* Text content */}
|
||||||
|
<div className="flex flex-col min-w-0 flex-1 gap-0.5">
|
||||||
|
<span className="text-[13px] font-medium text-foreground truncate leading-tight">
|
||||||
|
{getDocumentTypeLabel(value)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground leading-tight">
|
||||||
|
{typeCounts.get(value)} document
|
||||||
|
{(typeCounts.get(value) ?? 0) !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Checkbox */}
|
||||||
|
<Checkbox
|
||||||
|
id={`${id}-${i}`}
|
||||||
|
checked={activeTypes.includes(value)}
|
||||||
|
onCheckedChange={(checked: boolean) => onToggleType(value, !!checked)}
|
||||||
|
className="h-4 w-4 shrink-0 rounded border-muted-foreground/30 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{activeTypes.length > 0 && (
|
||||||
|
<div className="px-3 pt-1.5 pb-1.5 border-t border-neutral-700">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full h-7 text-[11px] text-muted-foreground hover:text-foreground hover:bg-neutral-700"
|
||||||
|
onClick={() => {
|
||||||
|
activeTypes.forEach((t) => {
|
||||||
|
onToggleType(t, false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<motion.div
|
<div className="relative flex-1 min-w-0">
|
||||||
className="relative w-[180px]"
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
|
||||||
>
|
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
|
||||||
<ListFilter size={14} aria-hidden="true" />
|
<Search size={14} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id={`${id}-input`}
|
id={`${id}-input`}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="peer h-9 w-full pl-9 pr-9 text-sm bg-background border-border/60 focus-visible:ring-1 focus-visible:ring-ring/30 select-none focus:select-text"
|
className="peer h-9 w-full pl-9 pr-9 text-sm bg-sidebar border-border/60 focus-visible:ring-1 focus-visible:ring-ring/30 select-none focus:select-text"
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onChange={(e) => onSearch(e.target.value)}
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
placeholder="Filter by title"
|
placeholder="Search docs"
|
||||||
type="text"
|
type="text"
|
||||||
aria-label={t("filter_placeholder")}
|
aria-label={t("filter_placeholder")}
|
||||||
/>
|
/>
|
||||||
{Boolean(searchValue) && (
|
{Boolean(searchValue) && (
|
||||||
<motion.button
|
<button
|
||||||
|
type="button"
|
||||||
className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
|
className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
|
||||||
aria-label="Clear filter"
|
aria-label="Clear filter"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSearch("");
|
onSearch("");
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.8 }}
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.9 }}
|
|
||||||
>
|
>
|
||||||
<X size={14} strokeWidth={2} aria-hidden="true" />
|
<X size={14} strokeWidth={2} aria-hidden="true" />
|
||||||
</motion.button>
|
</button>
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Filter Buttons Group */}
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{/* Type Filter */}
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 gap-2 border-dashed border-border/60 text-muted-foreground hover:text-foreground hover:border-border"
|
|
||||||
>
|
|
||||||
<FileType size={14} className="text-muted-foreground" />
|
|
||||||
<span className="hidden sm:inline">Type</span>
|
|
||||||
{activeTypes.length > 0 && (
|
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
|
|
||||||
{activeTypes.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-64 !p-0 overflow-hidden" align="end">
|
|
||||||
<div>
|
|
||||||
{/* Search input */}
|
|
||||||
<div className="p-2 border-b border-border/50">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search types"
|
|
||||||
value={typeSearchQuery}
|
|
||||||
onChange={(e) => setTypeSearchQuery(e.target.value)}
|
|
||||||
className="h-6 pl-6 text-sm bg-transparent border-0 focus-visible:ring-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden py-1.5 px-1.5">
|
|
||||||
{filteredTypes.length === 0 ? (
|
|
||||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
No types found
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredTypes.map((value: DocumentTypeEnum, i) => (
|
|
||||||
<div
|
|
||||||
key={value}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className="flex w-full items-center gap-2.5 py-2 px-3 rounded-md hover:bg-muted/50 transition-colors cursor-pointer text-left"
|
|
||||||
onClick={() => onToggleType(value, !activeTypes.includes(value))}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
onToggleType(value, !activeTypes.includes(value));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Icon */}
|
|
||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted/50 text-foreground/80">
|
|
||||||
{getDocumentTypeIcon(value, "h-4 w-4")}
|
|
||||||
</div>
|
|
||||||
{/* Text content */}
|
|
||||||
<div className="flex flex-col min-w-0 flex-1 gap-0.5">
|
|
||||||
<span className="text-[13px] font-medium text-foreground truncate leading-tight">
|
|
||||||
{getDocumentTypeLabel(value)}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] text-muted-foreground leading-tight">
|
|
||||||
{typeCounts.get(value)} document
|
|
||||||
{(typeCounts.get(value) ?? 0) !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* Checkbox */}
|
|
||||||
<Checkbox
|
|
||||||
id={`${id}-${i}`}
|
|
||||||
checked={activeTypes.includes(value)}
|
|
||||||
onCheckedChange={(checked: boolean) => onToggleType(value, !!checked)}
|
|
||||||
className="h-4 w-4 shrink-0 rounded border-muted-foreground/30 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{activeTypes.length > 0 && (
|
|
||||||
<div className="px-3 pt-1.5 pb-1.5 border-t border-border/50">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full h-7 text-[11px] text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => {
|
|
||||||
activeTypes.forEach((t) => {
|
|
||||||
onToggleType(t, false);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear filters
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Bulk Delete Button */}
|
|
||||||
{selectedIds.size > 0 && (
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
|
||||||
>
|
|
||||||
{/* Mobile: icon with count */}
|
|
||||||
<Button variant="destructive" size="sm" className="h-9 gap-1.5 px-2.5 md:hidden">
|
|
||||||
<Trash size={14} />
|
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-destructive-foreground/20 text-[10px] font-medium">
|
|
||||||
{selectedIds.size}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
{/* Desktop: full button */}
|
|
||||||
<Button variant="destructive" size="sm" className="h-9 gap-2 hidden md:flex">
|
|
||||||
<Trash size={14} />
|
|
||||||
Delete
|
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-destructive-foreground/20 text-[10px] font-medium">
|
|
||||||
{selectedIds.size}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent className="max-w-md">
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4">
|
|
||||||
<div
|
|
||||||
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<CircleAlert size={18} strokeWidth={2} />
|
|
||||||
</div>
|
|
||||||
<AlertDialogHeader className="flex-1">
|
|
||||||
<AlertDialogTitle>
|
|
||||||
Delete {selectedIds.size} document{selectedIds.size !== 1 ? "s" : ""}?
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This action cannot be undone. This will permanently delete the selected{" "}
|
|
||||||
{selectedIds.size === 1 ? "document" : "documents"} from your search space.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
</div>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={onBulkDelete}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Button */}
|
||||||
|
<Button
|
||||||
|
onClick={openUploadDialog}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 shrink-0 gap-1.5 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
<span>Upload</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -95,7 +95,6 @@ export function RowActions({
|
||||||
{/* Desktop Actions */}
|
{/* Desktop Actions */}
|
||||||
<div className="hidden md:inline-flex items-center justify-center">
|
<div className="hidden md:inline-flex items-center justify-center">
|
||||||
{isEditable ? (
|
{isEditable ? (
|
||||||
// Editable documents: show 3-dot dropdown with edit + delete
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -123,9 +122,7 @@ export function RowActions({
|
||||||
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
||||||
disabled={isDeleteDisabled}
|
disabled={isDeleteDisabled}
|
||||||
className={
|
className={
|
||||||
isDeleteDisabled
|
isDeleteDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
|
||||||
? "text-muted-foreground cursor-not-allowed opacity-50"
|
|
||||||
: "text-destructive focus:text-destructive"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
|
@ -135,12 +132,11 @@ export function RowActions({
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : (
|
) : (
|
||||||
// Non-editable documents: show only delete button directly
|
|
||||||
shouldShowDelete && (
|
shouldShowDelete && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-destructive hover:bg-destructive/10"}`}
|
className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-foreground"}`}
|
||||||
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
||||||
disabled={isDeleting || isDeleteDisabled}
|
disabled={isDeleting || isDeleteDisabled}
|
||||||
>
|
>
|
||||||
|
|
@ -154,7 +150,6 @@ export function RowActions({
|
||||||
{/* Mobile Actions Dropdown */}
|
{/* Mobile Actions Dropdown */}
|
||||||
<div className="inline-flex md:hidden items-center justify-center">
|
<div className="inline-flex md:hidden items-center justify-center">
|
||||||
{isEditable ? (
|
{isEditable ? (
|
||||||
// Editable documents: show 3-dot dropdown
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground">
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground">
|
||||||
|
|
@ -178,9 +173,7 @@ export function RowActions({
|
||||||
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
||||||
disabled={isDeleteDisabled}
|
disabled={isDeleteDisabled}
|
||||||
className={
|
className={
|
||||||
isDeleteDisabled
|
isDeleteDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
|
||||||
? "text-muted-foreground cursor-not-allowed opacity-50"
|
|
||||||
: "text-destructive focus:text-destructive"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
|
@ -190,12 +183,11 @@ export function RowActions({
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : (
|
) : (
|
||||||
// Non-editable documents: show only delete button directly
|
|
||||||
shouldShowDelete && (
|
shouldShowDelete && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-destructive hover:bg-destructive/10"}`}
|
className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-foreground"}`}
|
||||||
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
||||||
disabled={isDeleting || isDeleteDisabled}
|
disabled={isDeleting || isDeleteDisabled}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,317 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
|
||||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
|
||||||
import { useDocuments } from "@/hooks/use-documents";
|
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
import { DocumentsFilters } from "./components/DocumentsFilters";
|
|
||||||
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
|
|
||||||
import { PAGE_SIZE, PaginationControls } from "./components/PaginationControls";
|
|
||||||
import type { ColumnVisibility } from "./components/types";
|
|
||||||
|
|
||||||
function useDebounced<T>(value: T, delay = 250) {
|
|
||||||
const [debounced, setDebounced] = useState(value);
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setTimeout(() => setDebounced(value), delay);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [value, delay]);
|
|
||||||
return debounced;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DocumentsTable() {
|
|
||||||
const t = useTranslations("documents");
|
|
||||||
const params = useParams();
|
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const debouncedSearch = useDebounced(search, 250);
|
|
||||||
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
|
|
||||||
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility>({
|
|
||||||
document_type: true,
|
|
||||||
created_by: true,
|
|
||||||
created_at: true,
|
|
||||||
status: true,
|
|
||||||
});
|
|
||||||
const [pageIndex, setPageIndex] = useState(0);
|
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("created_at");
|
|
||||||
const [sortDesc, setSortDesc] = useState(true);
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
|
||||||
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
|
||||||
|
|
||||||
// REAL-TIME: Use Electric SQL hook for live document updates (when not searching)
|
|
||||||
const {
|
|
||||||
documents: realtimeDocuments,
|
|
||||||
typeCounts: realtimeTypeCounts,
|
|
||||||
loading: realtimeLoading,
|
|
||||||
error: realtimeError,
|
|
||||||
} = useDocuments(searchSpaceId, activeTypes);
|
|
||||||
|
|
||||||
// Check if we're in search mode
|
|
||||||
const isSearchMode = !!debouncedSearch.trim();
|
|
||||||
|
|
||||||
// Build search query parameters (only used when searching)
|
|
||||||
const searchQueryParams = useMemo(
|
|
||||||
() => ({
|
|
||||||
search_space_id: searchSpaceId,
|
|
||||||
page: pageIndex,
|
|
||||||
page_size: PAGE_SIZE,
|
|
||||||
title: debouncedSearch.trim(),
|
|
||||||
...(activeTypes.length > 0 && { document_types: activeTypes }),
|
|
||||||
}),
|
|
||||||
[searchSpaceId, pageIndex, activeTypes, debouncedSearch]
|
|
||||||
);
|
|
||||||
|
|
||||||
// API search query (only enabled when searching - Electric doesn't do full-text search)
|
|
||||||
const {
|
|
||||||
data: searchResponse,
|
|
||||||
isLoading: isSearchLoading,
|
|
||||||
refetch: refetchSearch,
|
|
||||||
error: searchError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams),
|
|
||||||
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
|
|
||||||
staleTime: 30 * 1000, // 30 seconds for search (shorter since it's on-demand)
|
|
||||||
enabled: !!searchSpaceId && isSearchMode,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Client-side sorting for real-time documents
|
|
||||||
const sortedRealtimeDocuments = useMemo(() => {
|
|
||||||
const docs = [...realtimeDocuments];
|
|
||||||
docs.sort((a, b) => {
|
|
||||||
const av = a[sortKey] ?? "";
|
|
||||||
const bv = b[sortKey] ?? "";
|
|
||||||
let cmp: number;
|
|
||||||
if (sortKey === "created_at") {
|
|
||||||
cmp = new Date(av as string).getTime() - new Date(bv as string).getTime();
|
|
||||||
} else {
|
|
||||||
cmp = String(av).localeCompare(String(bv));
|
|
||||||
}
|
|
||||||
return sortDesc ? -cmp : cmp;
|
|
||||||
});
|
|
||||||
return docs;
|
|
||||||
}, [realtimeDocuments, sortKey, sortDesc]);
|
|
||||||
|
|
||||||
// Client-side pagination for real-time documents
|
|
||||||
const paginatedRealtimeDocuments = useMemo(() => {
|
|
||||||
const start = pageIndex * PAGE_SIZE;
|
|
||||||
const end = start + PAGE_SIZE;
|
|
||||||
return sortedRealtimeDocuments.slice(start, end);
|
|
||||||
}, [sortedRealtimeDocuments, pageIndex]);
|
|
||||||
|
|
||||||
// Determine what to display based on search mode
|
|
||||||
const displayDocs = isSearchMode
|
|
||||||
? (searchResponse?.items || []).map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
search_space_id: item.search_space_id,
|
|
||||||
document_type: item.document_type,
|
|
||||||
title: item.title,
|
|
||||||
created_by_id: item.created_by_id ?? null,
|
|
||||||
created_by_name: item.created_by_name ?? null,
|
|
||||||
created_by_email: item.created_by_email ?? null,
|
|
||||||
created_at: item.created_at,
|
|
||||||
status: (
|
|
||||||
item as {
|
|
||||||
status?: { state: "ready" | "pending" | "processing" | "failed"; reason?: string };
|
|
||||||
}
|
|
||||||
).status ?? { state: "ready" as const },
|
|
||||||
}))
|
|
||||||
: paginatedRealtimeDocuments;
|
|
||||||
|
|
||||||
const displayTotal = isSearchMode ? searchResponse?.total || 0 : sortedRealtimeDocuments.length;
|
|
||||||
|
|
||||||
const loading = isSearchMode ? isSearchLoading : realtimeLoading;
|
|
||||||
const error = isSearchMode ? searchError : realtimeError;
|
|
||||||
|
|
||||||
const pageEnd = Math.min((pageIndex + 1) * PAGE_SIZE, displayTotal);
|
|
||||||
|
|
||||||
const onToggleType = (type: DocumentTypeEnum, checked: boolean) => {
|
|
||||||
setActiveTypes((prev) => {
|
|
||||||
if (checked) {
|
|
||||||
return prev.includes(type) ? prev : [...prev, type];
|
|
||||||
} else {
|
|
||||||
return prev.filter((t) => t !== type);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setPageIndex(0);
|
|
||||||
// Clear selections when filter changes — selected IDs from the previous
|
|
||||||
// filter view are no longer visible and would cause misleading bulk actions
|
|
||||||
setSelectedIds(new Set());
|
|
||||||
};
|
|
||||||
|
|
||||||
const onBulkDelete = async () => {
|
|
||||||
if (selectedIds.size === 0) {
|
|
||||||
toast.error(t("no_rows_selected"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out pending/processing documents - they cannot be deleted
|
|
||||||
// For real-time mode, use sortedRealtimeDocuments (which has status)
|
|
||||||
// For search mode, use searchResponse items (need to safely access status)
|
|
||||||
const allDocs = isSearchMode
|
|
||||||
? (searchResponse?.items || []).map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
status: (item as { status?: { state: string } }).status,
|
|
||||||
}))
|
|
||||||
: sortedRealtimeDocuments.map((doc) => ({ id: doc.id, status: doc.status }));
|
|
||||||
|
|
||||||
const selectedDocs = allDocs.filter((doc) => selectedIds.has(doc.id));
|
|
||||||
const deletableIds = selectedDocs
|
|
||||||
.filter((doc) => doc.status?.state !== "pending" && doc.status?.state !== "processing")
|
|
||||||
.map((doc) => doc.id);
|
|
||||||
const inProgressCount = selectedIds.size - deletableIds.length;
|
|
||||||
|
|
||||||
if (inProgressCount > 0) {
|
|
||||||
toast.warning(
|
|
||||||
`${inProgressCount} document(s) are pending or processing and cannot be deleted.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deletableIds.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Delete documents one by one using the mutation
|
|
||||||
// Track 409 conflicts separately (document started processing after UI loaded)
|
|
||||||
let conflictCount = 0;
|
|
||||||
const results = await Promise.all(
|
|
||||||
deletableIds.map(async (id) => {
|
|
||||||
try {
|
|
||||||
await deleteDocumentMutation({ id });
|
|
||||||
return true;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const status =
|
|
||||||
(error as { response?: { status?: number } })?.response?.status ??
|
|
||||||
(error as { status?: number })?.status;
|
|
||||||
if (status === 409) conflictCount++;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const okCount = results.filter((r) => r === true).length;
|
|
||||||
if (okCount === deletableIds.length) {
|
|
||||||
toast.success(t("delete_success_count", { count: okCount }));
|
|
||||||
} else if (conflictCount > 0) {
|
|
||||||
toast.error(`${conflictCount} document(s) started processing. Please try again later.`);
|
|
||||||
} else {
|
|
||||||
toast.error(t("delete_partial_failed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// If in search mode, refetch search results to reflect deletion
|
|
||||||
if (isSearchMode) {
|
|
||||||
await refetchSearch();
|
|
||||||
}
|
|
||||||
// Real-time mode: Electric will sync the deletion automatically
|
|
||||||
|
|
||||||
setSelectedIds(new Set());
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
toast.error(t("delete_error"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Single document delete handler for RowActions
|
|
||||||
const handleDeleteDocument = useCallback(
|
|
||||||
async (id: number): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
await deleteDocumentMutation({ id });
|
|
||||||
toast.success(t("delete_success") || "Document deleted");
|
|
||||||
// If in search mode, refetch search results to reflect deletion
|
|
||||||
if (isSearchMode) {
|
|
||||||
await refetchSearch();
|
|
||||||
}
|
|
||||||
// Real-time mode: Electric will sync the deletion automatically
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error deleting document:", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[deleteDocumentMutation, isSearchMode, refetchSearch, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSortChange = useCallback((key: SortKey) => {
|
|
||||||
setSortKey((currentKey) => {
|
|
||||||
if (currentKey === key) {
|
|
||||||
setSortDesc((v) => !v);
|
|
||||||
return currentKey;
|
|
||||||
}
|
|
||||||
setSortDesc(false);
|
|
||||||
return key;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reset page when search changes (type filter already resets via onToggleType)
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Intentionally reset page on search change
|
|
||||||
useEffect(() => {
|
|
||||||
setPageIndex(0);
|
|
||||||
}, [debouncedSearch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mq = window.matchMedia("(max-width: 768px)");
|
|
||||||
const apply = (isSmall: boolean) => {
|
|
||||||
setColumnVisibility((prev) => ({ ...prev, created_by: !isSmall, created_at: !isSmall }));
|
|
||||||
};
|
|
||||||
apply(mq.matches);
|
|
||||||
const onChange = (e: MediaQueryListEvent) => apply(e.matches);
|
|
||||||
mq.addEventListener("change", onChange);
|
|
||||||
return () => mq.removeEventListener("change", onChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="w-full max-w-7xl mx-auto px-6 pt-17 pb-6 space-y-6 min-h-[calc(100vh-64px)]"
|
|
||||||
>
|
|
||||||
{/* Filters - use real-time type counts */}
|
|
||||||
<DocumentsFilters
|
|
||||||
typeCounts={realtimeTypeCounts}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
onSearch={setSearch}
|
|
||||||
searchValue={search}
|
|
||||||
onBulkDelete={onBulkDelete}
|
|
||||||
onToggleType={onToggleType}
|
|
||||||
activeTypes={activeTypes}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<DocumentsTableShell
|
|
||||||
documents={displayDocs}
|
|
||||||
loading={!!loading}
|
|
||||||
error={!!error}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
setSelectedIds={setSelectedIds}
|
|
||||||
columnVisibility={columnVisibility}
|
|
||||||
sortKey={sortKey}
|
|
||||||
sortDesc={sortDesc}
|
|
||||||
onSortChange={handleSortChange}
|
|
||||||
deleteDocument={handleDeleteDocument}
|
|
||||||
searchSpaceId={String(searchSpaceId)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<PaginationControls
|
|
||||||
pageIndex={pageIndex}
|
|
||||||
total={displayTotal}
|
|
||||||
onFirst={() => setPageIndex(0)}
|
|
||||||
onPrev={() => setPageIndex((i) => Math.max(0, i - 1))}
|
|
||||||
onNext={() => setPageIndex((i) => (pageEnd < displayTotal ? i + 1 : i))}
|
|
||||||
onLast={() => setPageIndex(Math.max(0, Math.ceil(displayTotal / PAGE_SIZE) - 1))}
|
|
||||||
canPrev={pageIndex > 0}
|
|
||||||
canNext={pageEnd < displayTotal}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DocumentsTable };
|
|
||||||
|
|
@ -256,7 +256,7 @@ export default function EditorPage() {
|
||||||
|
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
toast.success("Note created successfully! Reindexing in background...");
|
toast.success("Note created successfully! Reindexing in background...");
|
||||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
} else {
|
} else {
|
||||||
// Existing document — save
|
// Existing document — save
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
|
|
@ -277,7 +277,7 @@ export default function EditorPage() {
|
||||||
|
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
toast.success("Document saved! Reindexing in background...");
|
toast.success("Document saved! Reindexing in background...");
|
||||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving document:", error);
|
console.error("Error saving document:", error);
|
||||||
|
|
@ -298,7 +298,7 @@ export default function EditorPage() {
|
||||||
if (hasUnsavedChanges) {
|
if (hasUnsavedChanges) {
|
||||||
setShowUnsavedDialog(true);
|
setShowUnsavedDialog(true);
|
||||||
} else {
|
} else {
|
||||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -311,7 +311,7 @@ export default function EditorPage() {
|
||||||
router.push(pendingNavigation);
|
router.push(pendingNavigation);
|
||||||
setPendingNavigation(null);
|
setPendingNavigation(null);
|
||||||
} else {
|
} else {
|
||||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,44 +10,7 @@ export default function DashboardLayout({
|
||||||
params: Promise<{ search_space_id: string }>;
|
params: Promise<{ search_space_id: string }>;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
// Use React.use to unwrap the params Promise
|
|
||||||
const { search_space_id } = use(params);
|
const { search_space_id } = use(params);
|
||||||
|
|
||||||
const customNavSecondary = [
|
return <DashboardClientLayout searchSpaceId={search_space_id}>{children}</DashboardClientLayout>;
|
||||||
{
|
|
||||||
title: `All Search Spaces`,
|
|
||||||
url: `#`,
|
|
||||||
icon: "Info",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: `All Search Spaces`,
|
|
||||||
url: "/dashboard",
|
|
||||||
icon: "Undo2",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const customNavMain = [
|
|
||||||
{
|
|
||||||
title: "Chat",
|
|
||||||
url: `/dashboard/${search_space_id}/new-chat`,
|
|
||||||
icon: "MessageCircle",
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Documents",
|
|
||||||
url: `/dashboard/${search_space_id}/documents`,
|
|
||||||
icon: "SquareLibrary",
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardClientLayout
|
|
||||||
searchSpaceId={search_space_id}
|
|
||||||
navSecondary={customNavSecondary}
|
|
||||||
navMain={customNavMain}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DashboardClientLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1222,7 +1222,6 @@ function LogRowActions({ row, t }: { row: Row<Log>; t: (key: string) => string }
|
||||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onSelect={(e) => {
|
onSelect={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
mentionedDocumentIdsAtom,
|
mentionedDocumentIdsAtom,
|
||||||
mentionedDocumentsAtom,
|
mentionedDocumentsAtom,
|
||||||
messageDocumentsMapAtom,
|
messageDocumentsMapAtom,
|
||||||
|
sidebarSelectedDocumentsAtom,
|
||||||
} from "@/atoms/chat/mentioned-documents.atom";
|
} from "@/atoms/chat/mentioned-documents.atom";
|
||||||
import {
|
import {
|
||||||
clearPlanOwnerRegistry,
|
clearPlanOwnerRegistry,
|
||||||
|
|
@ -31,7 +32,6 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { Thread } from "@/components/assistant-ui/thread";
|
import { Thread } from "@/components/assistant-ui/thread";
|
||||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
|
||||||
import { ReportPanel } from "@/components/report-panel/report-panel";
|
import { ReportPanel } from "@/components/report-panel/report-panel";
|
||||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||||
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
||||||
|
|
@ -180,11 +180,12 @@ export default function NewChatPage() {
|
||||||
interruptData: Record<string, unknown>;
|
interruptData: Record<string, unknown>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Get mentioned document IDs from the composer
|
// Get mentioned document IDs from the composer (derived from @ mentions + sidebar selections)
|
||||||
const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom);
|
const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom);
|
||||||
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
||||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
const sidebarDocuments = useAtomValue(sidebarSelectedDocumentsAtom);
|
||||||
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
|
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
|
||||||
|
const setSidebarDocuments = useSetAtom(sidebarSelectedDocumentsAtom);
|
||||||
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
||||||
const setCurrentThreadState = useSetAtom(currentThreadAtom);
|
const setCurrentThreadState = useSetAtom(currentThreadAtom);
|
||||||
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
|
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
|
||||||
|
|
@ -276,11 +277,8 @@ export default function NewChatPage() {
|
||||||
setThreadId(null);
|
setThreadId(null);
|
||||||
setCurrentThread(null);
|
setCurrentThread(null);
|
||||||
setMessageThinkingSteps(new Map());
|
setMessageThinkingSteps(new Map());
|
||||||
setMentionedDocumentIds({
|
|
||||||
surfsense_doc_ids: [],
|
|
||||||
document_ids: [],
|
|
||||||
});
|
|
||||||
setMentionedDocuments([]);
|
setMentionedDocuments([]);
|
||||||
|
setSidebarDocuments([]);
|
||||||
setMessageDocumentsMap({});
|
setMessageDocumentsMap({});
|
||||||
clearPlanOwnerRegistry(); // Reset plan ownership for new chat
|
clearPlanOwnerRegistry(); // Reset plan ownership for new chat
|
||||||
closeReportPanel(); // Close report panel when switching chats
|
closeReportPanel(); // Close report panel when switching chats
|
||||||
|
|
@ -345,8 +343,8 @@ export default function NewChatPage() {
|
||||||
}, [
|
}, [
|
||||||
urlChatId,
|
urlChatId,
|
||||||
setMessageDocumentsMap,
|
setMessageDocumentsMap,
|
||||||
setMentionedDocumentIds,
|
|
||||||
setMentionedDocuments,
|
setMentionedDocuments,
|
||||||
|
setSidebarDocuments,
|
||||||
closeReportPanel,
|
closeReportPanel,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -473,7 +471,7 @@ export default function NewChatPage() {
|
||||||
const newThread = await createThread(searchSpaceId, initialTitle);
|
const newThread = await createThread(searchSpaceId, initialTitle);
|
||||||
currentThreadId = newThread.id;
|
currentThreadId = newThread.id;
|
||||||
setThreadId(currentThreadId);
|
setThreadId(currentThreadId);
|
||||||
// Set currentThread so ChatHeader can show share button immediately
|
// Set currentThread so share button in header appears immediately
|
||||||
setCurrentThread(newThread);
|
setCurrentThread(newThread);
|
||||||
|
|
||||||
// Track chat creation
|
// Track chat creation
|
||||||
|
|
@ -528,31 +526,30 @@ export default function NewChatPage() {
|
||||||
messageLength: userQuery.length,
|
messageLength: userQuery.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store mentioned documents with this message for display
|
// Combine @-mention chips + sidebar selections for display & persistence
|
||||||
if (mentionedDocuments.length > 0) {
|
const allMentionedDocs: MentionedDocumentInfo[] = [];
|
||||||
const docsInfo: MentionedDocumentInfo[] = mentionedDocuments.map((doc) => ({
|
const seenDocKeys = new Set<string>();
|
||||||
id: doc.id,
|
for (const doc of [...mentionedDocuments, ...sidebarDocuments]) {
|
||||||
title: doc.title,
|
const key = `${doc.document_type}:${doc.id}`;
|
||||||
document_type: doc.document_type,
|
if (!seenDocKeys.has(key)) {
|
||||||
}));
|
seenDocKeys.add(key);
|
||||||
|
allMentionedDocs.push({ id: doc.id, title: doc.title, document_type: doc.document_type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allMentionedDocs.length > 0) {
|
||||||
setMessageDocumentsMap((prev) => ({
|
setMessageDocumentsMap((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[userMsgId]: docsInfo,
|
[userMsgId]: allMentionedDocs,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist user message with mentioned documents (don't await, fire and forget)
|
|
||||||
const persistContent: unknown[] = [...message.content];
|
const persistContent: unknown[] = [...message.content];
|
||||||
|
|
||||||
// Add mentioned documents for persistence
|
if (allMentionedDocs.length > 0) {
|
||||||
if (mentionedDocuments.length > 0) {
|
|
||||||
persistContent.push({
|
persistContent.push({
|
||||||
type: "mentioned-documents",
|
type: "mentioned-documents",
|
||||||
documents: mentionedDocuments.map((doc) => ({
|
documents: allMentionedDocs,
|
||||||
id: doc.id,
|
|
||||||
title: doc.title,
|
|
||||||
document_type: doc.document_type,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -560,7 +557,17 @@ export default function NewChatPage() {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: persistContent,
|
content: persistContent,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then((savedMessage) => {
|
||||||
|
const newUserMsgId = `msg-${savedMessage.id}`;
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) => (m.id === userMsgId ? { ...m, id: newUserMsgId } : m))
|
||||||
|
);
|
||||||
|
setMessageDocumentsMap((prev) => {
|
||||||
|
const docs = prev[userMsgId];
|
||||||
|
if (!docs) return prev;
|
||||||
|
const { [userMsgId]: _, ...rest } = prev;
|
||||||
|
return { ...rest, [newUserMsgId]: docs };
|
||||||
|
});
|
||||||
if (isNewThread) {
|
if (isNewThread) {
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
|
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
|
||||||
}
|
}
|
||||||
|
|
@ -618,11 +625,8 @@ export default function NewChatPage() {
|
||||||
|
|
||||||
// Clear mentioned documents after capturing them
|
// Clear mentioned documents after capturing them
|
||||||
if (hasDocumentIds || hasSurfsenseDocIds) {
|
if (hasDocumentIds || hasSurfsenseDocIds) {
|
||||||
setMentionedDocumentIds({
|
|
||||||
surfsense_doc_ids: [],
|
|
||||||
document_ids: [],
|
|
||||||
});
|
|
||||||
setMentionedDocuments([]);
|
setMentionedDocuments([]);
|
||||||
|
setSidebarDocuments([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
|
const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
|
||||||
|
|
@ -747,15 +751,6 @@ export default function NewChatPage() {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["threads", String(searchSpaceId)],
|
queryKey: ["threads", String(searchSpaceId)],
|
||||||
});
|
});
|
||||||
// Invalidate thread detail for breadcrumb update
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [
|
|
||||||
"threads",
|
|
||||||
String(searchSpaceId),
|
|
||||||
"detail",
|
|
||||||
String(titleData.threadId),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -920,8 +915,9 @@ export default function NewChatPage() {
|
||||||
messages,
|
messages,
|
||||||
mentionedDocumentIds,
|
mentionedDocumentIds,
|
||||||
mentionedDocuments,
|
mentionedDocuments,
|
||||||
setMentionedDocumentIds,
|
sidebarDocuments,
|
||||||
setMentionedDocuments,
|
setMentionedDocuments,
|
||||||
|
setSidebarDocuments,
|
||||||
setMessageDocumentsMap,
|
setMessageDocumentsMap,
|
||||||
queryClient,
|
queryClient,
|
||||||
currentThread,
|
currentThread,
|
||||||
|
|
@ -1674,10 +1670,7 @@ export default function NewChatPage() {
|
||||||
{/* <WriteTodosToolUI /> Disabled for now */}
|
{/* <WriteTodosToolUI /> Disabled for now */}
|
||||||
<div className="flex h-[calc(100dvh-64px)] overflow-hidden">
|
<div className="flex h-[calc(100dvh-64px)] overflow-hidden">
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
<Thread
|
<Thread messageThinkingSteps={messageThinkingSteps} />
|
||||||
messageThinkingSteps={messageThinkingSteps}
|
|
||||||
header={<ChatHeader searchSpaceId={searchSpaceId} />}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<ReportPanel />
|
<ReportPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -564,7 +564,7 @@ function MemberRow({
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="end"
|
align="end"
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
className="min-w-[120px] bg-muted dark:border dark:border-neutral-700"
|
className="min-w-[120px]"
|
||||||
>
|
>
|
||||||
{canManageRoles &&
|
{canManageRoles &&
|
||||||
roles
|
roles
|
||||||
|
|
@ -581,8 +581,8 @@ function MemberRow({
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -607,7 +607,7 @@ function MemberRow({
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`)
|
router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`)
|
||||||
|
|
@ -811,7 +811,7 @@ function CreateInviteDialog({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start text-left font-normal",
|
"w-full justify-start text-left font-normal bg-transparent",
|
||||||
!expiresAt && "text-muted-foreground"
|
!expiresAt && "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -833,7 +833,7 @@ function CreateInviteDialog({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={handleClose}>
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreate} disabled={creating}>
|
<Button onClick={handleCreate} disabled={creating}>
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@ button {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
|
@source "../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}";
|
||||||
@source '../node_modules/streamdown/dist/*.js';
|
@source "../node_modules/streamdown/dist/*.js";
|
||||||
@source '../node_modules/@streamdown/code/dist/*.js';
|
@source "../node_modules/@streamdown/code/dist/*.js";
|
||||||
@source '../node_modules/@streamdown/math/dist/*.js';
|
@source "../node_modules/@streamdown/math/dist/*.js";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
const backendBaseUrl = (process.env.INTERNAL_FASTAPI_BACKEND_URL || "http://backend:8000").replace(
|
const backendBaseUrl = (process.env.INTERNAL_FASTAPI_BACKEND_URL || "http://backend:8000").replace(
|
||||||
/\/+$/,
|
/\/+$/,
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,47 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import type { Document, SurfsenseDocsDocument } from "@/contracts/types/document.types";
|
import type { Document } from "@/contracts/types/document.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atom to store the IDs of documents mentioned in the current chat composer.
|
* Atom to store the full document objects mentioned via @-mention chips
|
||||||
* This is used to pass document context to the backend when sending a message.
|
* in the current chat composer. This persists across component remounts.
|
||||||
*/
|
|
||||||
export const mentionedDocumentIdsAtom = atom<{
|
|
||||||
surfsense_doc_ids: number[];
|
|
||||||
document_ids: number[];
|
|
||||||
}>({
|
|
||||||
surfsense_doc_ids: [],
|
|
||||||
document_ids: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atom to store the full document objects mentioned in the current chat composer.
|
|
||||||
* This persists across component remounts.
|
|
||||||
*/
|
*/
|
||||||
export const mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]);
|
export const mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atom to store documents selected via the sidebar checkboxes / row clicks.
|
||||||
|
* These are NOT inserted as chips – the composer shows a count badge instead.
|
||||||
|
*/
|
||||||
|
export const sidebarSelectedDocumentsAtom = atom<
|
||||||
|
Pick<Document, "id" | "title" | "document_type">[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived read-only atom that merges @-mention chips and sidebar selections
|
||||||
|
* into a single deduplicated set of document IDs for the backend.
|
||||||
|
*/
|
||||||
|
export const mentionedDocumentIdsAtom = atom((get) => {
|
||||||
|
const chipDocs = get(mentionedDocumentsAtom);
|
||||||
|
const sidebarDocs = get(sidebarSelectedDocumentsAtom);
|
||||||
|
const allDocs = [...chipDocs, ...sidebarDocs];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped = allDocs.filter((d) => {
|
||||||
|
const key = `${d.document_type}:${d.id}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
surfsense_doc_ids: deduped
|
||||||
|
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
document_ids: deduped
|
||||||
|
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
||||||
|
.map((doc) => doc.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simplified document info for display purposes
|
* Simplified document info for display purposes
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ export const uploadDocumentMutationAtom = atomWithMutation((get) => {
|
||||||
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Note: Toast notification is handled by the caller (DocumentUploadTab) to use i18n
|
// Note: Toast notification is handled by the caller (DocumentUploadTab) to use i18n
|
||||||
// Invalidate logs summary to show new processing tasks immediately on documents page
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined),
|
queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,5 @@ export const globalDocumentsQueryParamsAtom = atom<GetDocumentsRequest["queryPar
|
||||||
page_size: 10,
|
page_size: 10,
|
||||||
page: 0,
|
page: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const documentsSidebarOpenAtom = atom(false);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.1.2/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.6/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": true,
|
"ignoreUnknown": true,
|
||||||
"experimentalScannerIgnores": ["node_modules", ".git", ".next", "dist", "build", "coverage"],
|
"includes": ["**", "!!node_modules", "!!.git", "!!.next", "!!dist", "!!build", "!!coverage"],
|
||||||
"maxSize": 1048576
|
"maxSize": 1048576
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|
@ -65,6 +65,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"css": {
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"tailwindDirectives": true
|
||||||
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "tab",
|
"indentStyle": "tab",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Bell, ExternalLink, Info, type LucideIcon, Rocket, Wrench, Zap } from "lucide-react";
|
||||||
Bell,
|
|
||||||
ExternalLink,
|
|
||||||
Info,
|
|
||||||
type LucideIcon,
|
|
||||||
Rocket,
|
|
||||||
Wrench,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -114,4 +106,3 @@ export function AnnouncementCard({ announcement }: { announcement: AnnouncementW
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,3 @@ export function AnnouncementsEmptyState() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useAtomValue } from "jotai";
|
||||||
import { AlertTriangle, Cable, Settings } from "lucide-react";
|
import { AlertTriangle, Cable, Settings } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import type { FC } from "react";
|
import { type FC, useMemo } from "react";
|
||||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||||
import {
|
import {
|
||||||
globalNewLLMConfigsAtom,
|
globalNewLLMConfigsAtom,
|
||||||
|
|
@ -37,7 +37,7 @@ import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
|
||||||
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
||||||
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
||||||
|
|
||||||
export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger = false }) => {
|
export const ConnectorIndicator: FC = () => {
|
||||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||||
|
|
@ -66,11 +66,15 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
||||||
const { data: documentTypeCounts, isFetching: documentTypesLoading } =
|
const { data: documentTypeCounts, isFetching: documentTypesLoading } =
|
||||||
useAtomValue(documentTypeCountsAtom);
|
useAtomValue(documentTypeCountsAtom);
|
||||||
|
|
||||||
// Fetch notifications to detect indexing failures
|
// Fetch status notifications to detect indexing failures
|
||||||
const { inboxItems = [] } = useInbox(
|
const { inboxItems: statusInboxItems = [] } = useInbox(
|
||||||
currentUser?.id ?? null,
|
currentUser?.id ?? null,
|
||||||
searchSpaceId ? Number(searchSpaceId) : null,
|
searchSpaceId ? Number(searchSpaceId) : null,
|
||||||
"connector_indexing"
|
"status"
|
||||||
|
);
|
||||||
|
const inboxItems = useMemo(
|
||||||
|
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
|
||||||
|
[statusInboxItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if YouTube view is active
|
// Check if YouTube view is active
|
||||||
|
|
@ -189,40 +193,36 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
{!hideTrigger && (
|
<TooltipIconButton
|
||||||
<TooltipIconButton
|
data-joyride="connector-icon"
|
||||||
data-joyride="connector-icon"
|
tooltip={hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"}
|
||||||
tooltip={
|
side="bottom"
|
||||||
hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"
|
className={cn(
|
||||||
}
|
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
||||||
side="bottom"
|
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
|
||||||
className={cn(
|
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
|
||||||
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
|
||||||
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
|
)}
|
||||||
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
|
aria-label={
|
||||||
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
|
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
|
||||||
)}
|
}
|
||||||
aria-label={
|
onClick={() => handleOpenChange(true)}
|
||||||
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
|
>
|
||||||
}
|
{isLoading ? (
|
||||||
onClick={() => handleOpenChange(true)}
|
<Spinner size="sm" />
|
||||||
>
|
) : (
|
||||||
{isLoading ? (
|
<>
|
||||||
<Spinner size="sm" />
|
<Cable className="size-4 stroke-[1.5px]" />
|
||||||
) : (
|
{activeConnectorsCount > 0 && (
|
||||||
<>
|
<span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm select-none">
|
||||||
<Cable className="size-4 stroke-[1.5px]" />
|
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
|
||||||
{activeConnectorsCount > 0 && (
|
</span>
|
||||||
<span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm">
|
)}
|
||||||
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
|
</>
|
||||||
</span>
|
)}
|
||||||
)}
|
</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 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 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 ? (
|
||||||
|
|
@ -415,7 +415,6 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
||||||
activeDocumentTypes={activeDocumentTypes}
|
activeDocumentTypes={activeDocumentTypes}
|
||||||
connectors={connectors as SearchSourceConnector[]}
|
connectors={connectors as SearchSourceConnector[]}
|
||||||
indexingConnectorIds={indexingConnectorIds}
|
indexingConnectorIds={indexingConnectorIds}
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
onTabChange={handleTabChange}
|
onTabChange={handleTabChange}
|
||||||
onManage={handleStartEdit}
|
onManage={handleStartEdit}
|
||||||
onViewAccountsList={handleViewAccountsList}
|
onViewAccountsList={handleViewAccountsList}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowRight, Cable } from "lucide-react";
|
import { Cable } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
|
|
||||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants";
|
import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants";
|
||||||
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
||||||
|
|
@ -25,37 +20,21 @@ interface ActiveConnectorsTabProps {
|
||||||
activeDocumentTypes: Array<[string, number]>;
|
activeDocumentTypes: Array<[string, number]>;
|
||||||
connectors: SearchSourceConnector[];
|
connectors: SearchSourceConnector[];
|
||||||
indexingConnectorIds: Set<number>;
|
indexingConnectorIds: Set<number>;
|
||||||
searchSpaceId: string;
|
|
||||||
onTabChange: (value: string) => void;
|
onTabChange: (value: string) => void;
|
||||||
onManage?: (connector: SearchSourceConnector) => void;
|
onManage?: (connector: SearchSourceConnector) => void;
|
||||||
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
|
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a connector type is indexable
|
|
||||||
*/
|
|
||||||
function isIndexableConnector(connectorType: string): boolean {
|
|
||||||
const nonIndexableTypes = ["MCP_CONNECTOR"];
|
|
||||||
return !nonIndexableTypes.includes(connectorType);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
searchQuery,
|
searchQuery,
|
||||||
hasSources,
|
hasSources,
|
||||||
activeDocumentTypes,
|
activeDocumentTypes,
|
||||||
connectors,
|
connectors,
|
||||||
indexingConnectorIds,
|
indexingConnectorIds,
|
||||||
searchSpaceId,
|
onTabChange: _onTabChange,
|
||||||
onTabChange,
|
|
||||||
onManage,
|
onManage,
|
||||||
onViewAccountsList,
|
onViewAccountsList,
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleViewAllDocuments = () => {
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert activeDocumentTypes array to Record for utility function
|
// Convert activeDocumentTypes array to Record for utility function
|
||||||
const documentTypeCounts = activeDocumentTypes.reduce(
|
const documentTypeCounts = activeDocumentTypes.reduce(
|
||||||
(acc, [docType, count]) => {
|
(acc, [docType, count]) => {
|
||||||
|
|
@ -300,15 +279,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground">Documents</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground">Documents</h3>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleViewAllDocuments}
|
|
||||||
className="h-7 text-xs text-muted-foreground hover:text-foreground gap-1.5"
|
|
||||||
>
|
|
||||||
View all documents
|
|
||||||
<ArrowRight className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{standaloneDocuments.map((doc) => (
|
{standaloneDocuments.map((doc) => (
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { TagInput, type Tag as TagType } from "emblor";
|
import { TagInput, type Tag as TagType } from "emblor";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -24,7 +23,6 @@ interface YouTubeCrawlerViewProps {
|
||||||
|
|
||||||
export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId, onBack }) => {
|
export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId, onBack }) => {
|
||||||
const t = useTranslations("add_youtube");
|
const t = useTranslations("add_youtube");
|
||||||
const router = useRouter();
|
|
||||||
const [videoTags, setVideoTags] = useState<TagType[]>([]);
|
const [videoTags, setVideoTags] = useState<TagType[]>([]);
|
||||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -74,9 +72,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
||||||
toast(t("success_toast"), {
|
toast(t("success_toast"), {
|
||||||
description: t("success_toast_desc"),
|
description: t("success_toast_desc"),
|
||||||
});
|
});
|
||||||
// Close the popup and navigate to documents
|
|
||||||
onBack();
|
onBack();
|
||||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
const errorMessage = error instanceof Error ? error.message : t("error_generic");
|
const errorMessage = error instanceof Error ? error.message : t("error_generic");
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,12 @@ const DocumentUploadPopupContent: FC<{
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5">
|
<DialogContent
|
||||||
|
onPointerDownOutside={(e) => e.preventDefault()}
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||||
|
className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"
|
||||||
|
>
|
||||||
<DialogTitle className="sr-only">Upload Document</DialogTitle>
|
<DialogTitle className="sr-only">Upload Document</DialogTitle>
|
||||||
|
|
||||||
{/* Scrollable container for mobile */}
|
{/* Scrollable container for mobile */}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export interface InlineMentionEditorRef {
|
||||||
getText: () => string;
|
getText: () => string;
|
||||||
getMentionedDocuments: () => MentionedDocument[];
|
getMentionedDocuments: () => MentionedDocument[];
|
||||||
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
||||||
|
removeDocumentChip: (docId: number, docType?: string) => void;
|
||||||
setDocumentChipStatus: (
|
setDocumentChipStatus: (
|
||||||
docId: number,
|
docId: number,
|
||||||
docType: string | undefined,
|
docType: string | undefined,
|
||||||
|
|
@ -175,33 +176,27 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
|
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
|
||||||
chip.contentEditable = "false";
|
chip.contentEditable = "false";
|
||||||
chip.className =
|
chip.className =
|
||||||
"inline-flex items-center gap-1 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none";
|
"inline-flex items-center gap-1 mx-0.5 px-1 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none cursor-default";
|
||||||
chip.style.userSelect = "none";
|
chip.style.userSelect = "none";
|
||||||
chip.style.verticalAlign = "baseline";
|
chip.style.verticalAlign = "baseline";
|
||||||
|
|
||||||
// Add document type icon
|
// Container that swaps between icon and remove button on hover
|
||||||
|
const iconContainer = document.createElement("span");
|
||||||
|
iconContainer.className = "shrink-0 flex items-center size-3 relative";
|
||||||
|
|
||||||
const iconSpan = document.createElement("span");
|
const iconSpan = document.createElement("span");
|
||||||
iconSpan.className = "shrink-0 flex items-center text-muted-foreground";
|
iconSpan.className = "flex items-center text-muted-foreground";
|
||||||
iconSpan.innerHTML = ReactDOMServer.renderToString(
|
iconSpan.innerHTML = ReactDOMServer.renderToString(
|
||||||
getConnectorIcon(doc.document_type ?? "UNKNOWN", "h-3 w-3")
|
getConnectorIcon(doc.document_type ?? "UNKNOWN", "h-3 w-3")
|
||||||
);
|
);
|
||||||
|
|
||||||
const titleSpan = document.createElement("span");
|
|
||||||
titleSpan.className = "max-w-[120px] truncate";
|
|
||||||
titleSpan.textContent = doc.title;
|
|
||||||
titleSpan.title = doc.title;
|
|
||||||
titleSpan.setAttribute("data-mention-title", "true");
|
|
||||||
|
|
||||||
const statusSpan = document.createElement("span");
|
|
||||||
statusSpan.setAttribute(CHIP_STATUS_ATTR, "true");
|
|
||||||
statusSpan.className = "text-[10px] font-semibold opacity-80 hidden";
|
|
||||||
|
|
||||||
const removeBtn = document.createElement("button");
|
const removeBtn = document.createElement("button");
|
||||||
removeBtn.type = "button";
|
removeBtn.type = "button";
|
||||||
removeBtn.className =
|
removeBtn.className =
|
||||||
"size-3 flex items-center justify-center rounded-full hover:bg-primary/20 transition-colors ml-0.5";
|
"size-3 items-center justify-center rounded-full text-muted-foreground transition-colors";
|
||||||
|
removeBtn.style.display = "none";
|
||||||
removeBtn.innerHTML = ReactDOMServer.renderToString(
|
removeBtn.innerHTML = ReactDOMServer.renderToString(
|
||||||
createElement(X, { className: "h-2.5 w-2.5", strokeWidth: 2.5 })
|
createElement(X, { className: "h-3 w-3", strokeWidth: 2.5 })
|
||||||
);
|
);
|
||||||
removeBtn.onclick = (e) => {
|
removeBtn.onclick = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -213,15 +208,45 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
next.delete(docKey);
|
next.delete(docKey);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
// Notify parent that a document was removed
|
|
||||||
onDocumentRemove?.(doc.id, doc.document_type);
|
onDocumentRemove?.(doc.id, doc.document_type);
|
||||||
focusAtEnd();
|
focusAtEnd();
|
||||||
};
|
};
|
||||||
|
|
||||||
chip.appendChild(iconSpan);
|
const titleSpan = document.createElement("span");
|
||||||
chip.appendChild(titleSpan);
|
titleSpan.className = "max-w-[120px] truncate";
|
||||||
chip.appendChild(statusSpan);
|
titleSpan.textContent = doc.title;
|
||||||
chip.appendChild(removeBtn);
|
titleSpan.title = doc.title;
|
||||||
|
titleSpan.setAttribute("data-mention-title", "true");
|
||||||
|
|
||||||
|
const statusSpan = document.createElement("span");
|
||||||
|
statusSpan.setAttribute(CHIP_STATUS_ATTR, "true");
|
||||||
|
statusSpan.className = "text-[10px] font-semibold opacity-80 hidden";
|
||||||
|
|
||||||
|
const isTouchDevice = window.matchMedia("(hover: none)").matches;
|
||||||
|
if (isTouchDevice) {
|
||||||
|
// Mobile: icon on left, title, X on right
|
||||||
|
chip.appendChild(iconSpan);
|
||||||
|
chip.appendChild(titleSpan);
|
||||||
|
chip.appendChild(statusSpan);
|
||||||
|
removeBtn.style.display = "flex";
|
||||||
|
removeBtn.className += " ml-0.5";
|
||||||
|
chip.appendChild(removeBtn);
|
||||||
|
} else {
|
||||||
|
// Desktop: icon/X swap on hover in the same slot
|
||||||
|
iconContainer.appendChild(iconSpan);
|
||||||
|
iconContainer.appendChild(removeBtn);
|
||||||
|
chip.addEventListener("mouseenter", () => {
|
||||||
|
iconSpan.style.display = "none";
|
||||||
|
removeBtn.style.display = "flex";
|
||||||
|
});
|
||||||
|
chip.addEventListener("mouseleave", () => {
|
||||||
|
iconSpan.style.display = "";
|
||||||
|
removeBtn.style.display = "none";
|
||||||
|
});
|
||||||
|
chip.appendChild(iconContainer);
|
||||||
|
chip.appendChild(titleSpan);
|
||||||
|
chip.appendChild(statusSpan);
|
||||||
|
}
|
||||||
|
|
||||||
return chip;
|
return chip;
|
||||||
},
|
},
|
||||||
|
|
@ -388,6 +413,32 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const removeDocumentChip = useCallback(
|
||||||
|
(docId: number, docType?: string) => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
const chipKey = `${docType ?? "UNKNOWN"}:${docId}`;
|
||||||
|
const chips = editorRef.current.querySelectorAll<HTMLSpanElement>(
|
||||||
|
`span[${CHIP_DATA_ATTR}="true"]`
|
||||||
|
);
|
||||||
|
for (const chip of chips) {
|
||||||
|
if (getChipId(chip) === docId && getChipDocType(chip) === (docType ?? "UNKNOWN")) {
|
||||||
|
chip.remove();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMentionedDocs((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(chipKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = getText();
|
||||||
|
const empty = text.length === 0 && mentionedDocs.size <= 1;
|
||||||
|
setIsEmpty(empty);
|
||||||
|
},
|
||||||
|
[getText, mentionedDocs.size]
|
||||||
|
);
|
||||||
|
|
||||||
// Expose methods via ref
|
// Expose methods via ref
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => editorRef.current?.focus(),
|
focus: () => editorRef.current?.focus(),
|
||||||
|
|
@ -395,6 +446,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
getText,
|
getText,
|
||||||
getMentionedDocuments,
|
getMentionedDocuments,
|
||||||
insertDocumentChip,
|
insertDocumentChip,
|
||||||
|
removeDocumentChip,
|
||||||
setDocumentChipStatus,
|
setDocumentChipStatus,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,7 @@ function ThreadListItemComponent({
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
<DropdownMenuItem onClick={onDelete}>
|
||||||
<TrashIcon className="mr-2 size-4" />
|
<TrashIcon className="mr-2 size-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -18,23 +18,21 @@ import {
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
Dot,
|
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
FileWarning,
|
PlusIcon,
|
||||||
Paperclip,
|
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
SquareIcon,
|
SquareIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
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 { toast } from "sonner";
|
|
||||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||||
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
|
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import {
|
import {
|
||||||
mentionedDocumentIdsAtom,
|
|
||||||
mentionedDocumentsAtom,
|
mentionedDocumentsAtom,
|
||||||
|
sidebarSelectedDocumentsAtom,
|
||||||
} from "@/atoms/chat/mentioned-documents.atom";
|
} from "@/atoms/chat/mentioned-documents.atom";
|
||||||
|
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import {
|
import {
|
||||||
globalNewLLMConfigsAtom,
|
globalNewLLMConfigsAtom,
|
||||||
|
|
@ -45,6 +43,7 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||||
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
||||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||||
|
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import {
|
import {
|
||||||
InlineMentionEditor,
|
InlineMentionEditor,
|
||||||
type InlineMentionEditorRef,
|
type InlineMentionEditorRef,
|
||||||
|
|
@ -63,11 +62,9 @@ import {
|
||||||
} from "@/components/new-chat/document-mention-picker";
|
} from "@/components/new-chat/document-mention-picker";
|
||||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
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 { documentsApiService } from "@/lib/apis/documents-api.service";
|
|
||||||
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 */
|
||||||
|
|
@ -80,37 +77,19 @@ const CYCLING_PLACEHOLDERS = [
|
||||||
"Check if this week's Slack messages reference any GitHub issues.",
|
"Check if this week's Slack messages reference any GitHub issues.",
|
||||||
];
|
];
|
||||||
|
|
||||||
const CHAT_UPLOAD_ACCEPT =
|
|
||||||
".pdf,.doc,.docx,.txt,.md,.markdown,.ppt,.pptx,.xls,.xlsx,.xlsm,.xlsb,.csv,.html,.htm,.xml,.rtf,.epub,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.tif,.mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm";
|
|
||||||
|
|
||||||
const CHAT_MAX_FILES = 10;
|
|
||||||
const CHAT_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB per file
|
|
||||||
const CHAT_MAX_TOTAL_SIZE_BYTES = 200 * 1024 * 1024; // 200 MB total
|
|
||||||
|
|
||||||
type UploadState = "pending" | "processing" | "ready" | "failed";
|
|
||||||
|
|
||||||
interface UploadedMentionDoc {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
document_type: Document["document_type"];
|
|
||||||
state: UploadState;
|
|
||||||
reason?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThreadProps {
|
interface ThreadProps {
|
||||||
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
||||||
header?: React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map(), header }) => {
|
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) => {
|
||||||
return (
|
return (
|
||||||
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
|
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
|
||||||
<ThreadContent header={header} />
|
<ThreadContent />
|
||||||
</ThinkingStepsContext.Provider>
|
</ThinkingStepsContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
const ThreadContent: FC = () => {
|
||||||
const showGutter = useAtomValue(showCommentsGutterAtom);
|
const showGutter = useAtomValue(showCommentsGutterAtom);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -128,8 +107,6 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
||||||
showGutter && "lg:pr-30"
|
showGutter && "lg:pr-30"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
|
|
||||||
|
|
||||||
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
||||||
<ThreadWelcome />
|
<ThreadWelcome />
|
||||||
</AssistantIf>
|
</AssistantIf>
|
||||||
|
|
@ -250,19 +227,13 @@ const ThreadWelcome: FC = () => {
|
||||||
const Composer: FC = () => {
|
const Composer: FC = () => {
|
||||||
// Document mention state (atoms persist across component remounts)
|
// Document mention state (atoms persist across component remounts)
|
||||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||||
|
const setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom);
|
||||||
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
||||||
const [mentionQuery, setMentionQuery] = useState("");
|
const [mentionQuery, setMentionQuery] = useState("");
|
||||||
const [uploadedMentionDocs, setUploadedMentionDocs] = useState<
|
|
||||||
Record<number, UploadedMentionDoc>
|
|
||||||
>({});
|
|
||||||
const [isUploadingDocs, setIsUploadingDocs] = useState(false);
|
|
||||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const isFileDialogOpenRef = useRef(false);
|
|
||||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||||
const { search_space_id, chat_id } = useParams();
|
const { search_space_id, chat_id } = useParams();
|
||||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
|
||||||
const composerRuntime = useComposerRuntime();
|
const composerRuntime = useComposerRuntime();
|
||||||
const hasAutoFocusedRef = useRef(false);
|
const hasAutoFocusedRef = useRef(false);
|
||||||
|
|
||||||
|
|
@ -317,7 +288,7 @@ const Composer: FC = () => {
|
||||||
const assistantIdsKey = useAssistantState(({ thread }) =>
|
const assistantIdsKey = useAssistantState(({ thread }) =>
|
||||||
thread.messages
|
thread.messages
|
||||||
.filter((m) => m.role === "assistant" && m.id?.startsWith("msg-"))
|
.filter((m) => m.role === "assistant" && m.id?.startsWith("msg-"))
|
||||||
.map((m) => m.id!.replace("msg-", ""))
|
.map((m) => m.id?.replace("msg-", ""))
|
||||||
.join(",")
|
.join(",")
|
||||||
);
|
);
|
||||||
const assistantDbMessageIds = useMemo(
|
const assistantDbMessageIds = useMemo(
|
||||||
|
|
@ -337,18 +308,6 @@ const Composer: FC = () => {
|
||||||
}
|
}
|
||||||
}, [isThreadEmpty]);
|
}, [isThreadEmpty]);
|
||||||
|
|
||||||
// Sync mentioned document IDs to atom for inclusion in chat request payload
|
|
||||||
useEffect(() => {
|
|
||||||
setMentionedDocumentIds({
|
|
||||||
surfsense_doc_ids: mentionedDocuments
|
|
||||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
document_ids: mentionedDocuments
|
|
||||||
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
});
|
|
||||||
}, [mentionedDocuments, setMentionedDocumentIds]);
|
|
||||||
|
|
||||||
// Sync editor text with assistant-ui composer runtime
|
// Sync editor text with assistant-ui composer runtime
|
||||||
const handleEditorChange = useCallback(
|
const handleEditorChange = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
|
|
@ -401,75 +360,35 @@ const Composer: FC = () => {
|
||||||
[showDocumentPopover]
|
[showDocumentPopover]
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadedMentionedDocs = useMemo(
|
|
||||||
() => mentionedDocuments.filter((doc) => uploadedMentionDocs[doc.id]),
|
|
||||||
[mentionedDocuments, uploadedMentionDocs]
|
|
||||||
);
|
|
||||||
|
|
||||||
const blockingUploadedMentions = useMemo(
|
|
||||||
() =>
|
|
||||||
uploadedMentionedDocs.filter((doc) => {
|
|
||||||
const state = uploadedMentionDocs[doc.id]?.state;
|
|
||||||
return state === "pending" || state === "processing" || state === "failed";
|
|
||||||
}),
|
|
||||||
[uploadedMentionedDocs, uploadedMentionDocs]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
|
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (
|
if (isThreadRunning || isBlockedByOtherUser) {
|
||||||
isThreadRunning ||
|
|
||||||
isBlockedByOtherUser ||
|
|
||||||
isUploadingDocs ||
|
|
||||||
blockingUploadedMentions.length > 0
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!showDocumentPopover) {
|
if (!showDocumentPopover) {
|
||||||
composerRuntime.send();
|
composerRuntime.send();
|
||||||
editorRef.current?.clear();
|
editorRef.current?.clear();
|
||||||
setMentionedDocuments([]);
|
setMentionedDocuments([]);
|
||||||
setMentionedDocumentIds({
|
setSidebarDocs([]);
|
||||||
surfsense_doc_ids: [],
|
|
||||||
document_ids: [],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
showDocumentPopover,
|
showDocumentPopover,
|
||||||
isThreadRunning,
|
isThreadRunning,
|
||||||
isBlockedByOtherUser,
|
isBlockedByOtherUser,
|
||||||
isUploadingDocs,
|
|
||||||
blockingUploadedMentions.length,
|
|
||||||
composerRuntime,
|
composerRuntime,
|
||||||
setMentionedDocuments,
|
setMentionedDocuments,
|
||||||
setMentionedDocumentIds,
|
setSidebarDocs,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Remove document from mentions and sync IDs to atom
|
|
||||||
const handleDocumentRemove = useCallback(
|
const handleDocumentRemove = useCallback(
|
||||||
(docId: number, docType?: string) => {
|
(docId: number, docType?: string) => {
|
||||||
setMentionedDocuments((prev) => {
|
setMentionedDocuments((prev) =>
|
||||||
const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
|
prev.filter((doc) => !(doc.id === docId && doc.document_type === docType))
|
||||||
setMentionedDocumentIds({
|
);
|
||||||
surfsense_doc_ids: updated
|
|
||||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
document_ids: updated
|
|
||||||
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
setUploadedMentionDocs((prev) => {
|
|
||||||
if (!(docId in prev)) return prev;
|
|
||||||
const { [docId]: _removed, ...rest } = prev;
|
|
||||||
return rest;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[setMentionedDocuments, setMentionedDocumentIds]
|
[setMentionedDocuments]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add selected documents from picker, insert chips, and sync IDs to atom
|
|
||||||
const handleDocumentsMention = useCallback(
|
const handleDocumentsMention = useCallback(
|
||||||
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
||||||
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
|
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
|
||||||
|
|
@ -486,185 +405,14 @@ const Composer: FC = () => {
|
||||||
const uniqueNewDocs = documents.filter(
|
const uniqueNewDocs = documents.filter(
|
||||||
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
|
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
|
||||||
);
|
);
|
||||||
const updated = [...prev, ...uniqueNewDocs];
|
return [...prev, ...uniqueNewDocs];
|
||||||
setMentionedDocumentIds({
|
|
||||||
surfsense_doc_ids: updated
|
|
||||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
document_ids: updated
|
|
||||||
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
|
||||||
.map((doc) => doc.id),
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
},
|
},
|
||||||
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
[mentionedDocuments, setMentionedDocuments]
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshUploadedDocStatuses = useCallback(
|
|
||||||
async (documentIds: number[]) => {
|
|
||||||
if (!search_space_id || documentIds.length === 0) return;
|
|
||||||
const statusResponse = await documentsApiService.getDocumentsStatus({
|
|
||||||
queryParams: {
|
|
||||||
search_space_id: Number(search_space_id),
|
|
||||||
document_ids: documentIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setUploadedMentionDocs((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
for (const item of statusResponse.items) {
|
|
||||||
next[item.id] = {
|
|
||||||
id: item.id,
|
|
||||||
title: item.title,
|
|
||||||
document_type: item.document_type,
|
|
||||||
state: item.status.state,
|
|
||||||
reason: item.status.reason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
handleDocumentsMention(
|
|
||||||
statusResponse.items.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
title: item.title,
|
|
||||||
document_type: item.document_type,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[search_space_id, handleDocumentsMention]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUploadClick = useCallback(() => {
|
|
||||||
if (isFileDialogOpenRef.current) return;
|
|
||||||
isFileDialogOpenRef.current = true;
|
|
||||||
uploadInputRef.current?.click();
|
|
||||||
// Reset after a delay to handle cancellation (which doesn't fire the change event).
|
|
||||||
setTimeout(() => {
|
|
||||||
isFileDialogOpenRef.current = false;
|
|
||||||
}, 1000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleUploadInputChange = useCallback(
|
|
||||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
isFileDialogOpenRef.current = false;
|
|
||||||
const files = Array.from(event.target.files ?? []);
|
|
||||||
event.target.value = "";
|
|
||||||
if (files.length === 0 || !search_space_id) return;
|
|
||||||
|
|
||||||
if (files.length > CHAT_MAX_FILES) {
|
|
||||||
toast.error(`Too many files. Maximum ${CHAT_MAX_FILES} files per upload.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalSize = 0;
|
|
||||||
for (const file of files) {
|
|
||||||
if (file.size > CHAT_MAX_FILE_SIZE_BYTES) {
|
|
||||||
toast.error(
|
|
||||||
`File "${file.name}" (${(file.size / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB per-file limit.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
totalSize += file.size;
|
|
||||||
}
|
|
||||||
if (totalSize > CHAT_MAX_TOTAL_SIZE_BYTES) {
|
|
||||||
toast.error(
|
|
||||||
`Total upload size (${(totalSize / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_TOTAL_SIZE_BYTES / (1024 * 1024)} MB limit.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUploadingDocs(true);
|
|
||||||
try {
|
|
||||||
const uploadResponse = await documentsApiService.uploadDocument({
|
|
||||||
files,
|
|
||||||
search_space_id: Number(search_space_id),
|
|
||||||
});
|
|
||||||
const uploadedIds = uploadResponse.document_ids ?? [];
|
|
||||||
const duplicateIds = uploadResponse.duplicate_document_ids ?? [];
|
|
||||||
const idsToMention = Array.from(new Set([...uploadedIds, ...duplicateIds]));
|
|
||||||
if (idsToMention.length === 0) {
|
|
||||||
toast.warning("No documents were created or matched from selected files.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshUploadedDocStatuses(idsToMention);
|
|
||||||
if (uploadedIds.length > 0 && duplicateIds.length > 0) {
|
|
||||||
toast.success(
|
|
||||||
`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""} and matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""}.`
|
|
||||||
);
|
|
||||||
} else if (uploadedIds.length > 0) {
|
|
||||||
toast.success(`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""}`);
|
|
||||||
} else {
|
|
||||||
toast.success(
|
|
||||||
`Matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""} and added mention${duplicateIds.length > 1 ? "s" : ""}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Upload failed";
|
|
||||||
toast.error(`Upload failed: ${message}`);
|
|
||||||
} finally {
|
|
||||||
setIsUploadingDocs(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[search_space_id, refreshUploadedDocStatuses]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Poll status for uploaded mentioned documents until all are ready or removed.
|
|
||||||
useEffect(() => {
|
|
||||||
const trackedIds = uploadedMentionedDocs.map((doc) => doc.id);
|
|
||||||
const needsPolling = trackedIds.some((id) => {
|
|
||||||
const state = uploadedMentionDocs[id]?.state;
|
|
||||||
return state === "pending" || state === "processing";
|
|
||||||
});
|
|
||||||
if (!needsPolling) return;
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
refreshUploadedDocStatuses(trackedIds).catch((error) => {
|
|
||||||
console.error("[Composer] Failed to refresh uploaded mention statuses:", error);
|
|
||||||
});
|
|
||||||
}, 2500);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [uploadedMentionedDocs, uploadedMentionDocs, refreshUploadedDocStatuses]);
|
|
||||||
|
|
||||||
// Push upload status directly onto mention chips (instead of separate status rows).
|
|
||||||
useEffect(() => {
|
|
||||||
for (const doc of uploadedMentionedDocs) {
|
|
||||||
const state = uploadedMentionDocs[doc.id]?.state ?? "pending";
|
|
||||||
const statusLabel =
|
|
||||||
state === "ready"
|
|
||||||
? null
|
|
||||||
: state === "failed"
|
|
||||||
? "failed"
|
|
||||||
: state === "processing"
|
|
||||||
? "indexing"
|
|
||||||
: "queued";
|
|
||||||
editorRef.current?.setDocumentChipStatus(doc.id, doc.document_type, statusLabel, state);
|
|
||||||
}
|
|
||||||
}, [uploadedMentionedDocs, uploadedMentionDocs]);
|
|
||||||
|
|
||||||
// Prune upload status entries that are no longer mentioned in the composer.
|
|
||||||
useEffect(() => {
|
|
||||||
const activeIds = new Set(mentionedDocuments.map((doc) => doc.id));
|
|
||||||
setUploadedMentionDocs((prev) => {
|
|
||||||
let changed = false;
|
|
||||||
const next: Record<number, UploadedMentionDoc> = {};
|
|
||||||
for (const [key, value] of Object.entries(prev)) {
|
|
||||||
const id = Number(key);
|
|
||||||
if (activeIds.has(id)) {
|
|
||||||
next[id] = value;
|
|
||||||
} else {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed ? next : prev;
|
|
||||||
});
|
|
||||||
}, [mentionedDocuments]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
||||||
<ChatSessionStatus
|
<ChatSessionStatus
|
||||||
|
|
@ -688,15 +436,6 @@ const Composer: FC = () => {
|
||||||
className="min-h-[24px]"
|
className="min-h-[24px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
ref={uploadInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept={CHAT_UPLOAD_ACCEPT}
|
|
||||||
onChange={handleUploadInputChange}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Document picker popover (portal to body for proper z-index stacking) */}
|
{/* Document picker popover (portal to body for proper z-index stacking) */}
|
||||||
{showDocumentPopover &&
|
{showDocumentPopover &&
|
||||||
typeof document !== "undefined" &&
|
typeof document !== "undefined" &&
|
||||||
|
|
@ -722,15 +461,7 @@ const Composer: FC = () => {
|
||||||
/>,
|
/>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
<ComposerAction
|
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||||
isBlockedByOtherUser={isBlockedByOtherUser}
|
|
||||||
onUploadClick={handleUploadClick}
|
|
||||||
isUploadingDocs={isUploadingDocs}
|
|
||||||
blockingUploadedMentionsCount={blockingUploadedMentions.length}
|
|
||||||
hasFailedUploadedMentions={blockingUploadedMentions.some(
|
|
||||||
(doc) => uploadedMentionDocs[doc.id]?.state === "failed"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</ComposerPrimitive.Root>
|
</ComposerPrimitive.Root>
|
||||||
);
|
);
|
||||||
|
|
@ -738,29 +469,20 @@ const Composer: FC = () => {
|
||||||
|
|
||||||
interface ComposerActionProps {
|
interface ComposerActionProps {
|
||||||
isBlockedByOtherUser?: boolean;
|
isBlockedByOtherUser?: boolean;
|
||||||
onUploadClick: () => void;
|
|
||||||
isUploadingDocs: boolean;
|
|
||||||
blockingUploadedMentionsCount: number;
|
|
||||||
hasFailedUploadedMentions: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ComposerAction: FC<ComposerActionProps> = ({
|
const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
|
||||||
isBlockedByOtherUser = false,
|
|
||||||
onUploadClick,
|
|
||||||
isUploadingDocs,
|
|
||||||
blockingUploadedMentionsCount,
|
|
||||||
hasFailedUploadedMentions,
|
|
||||||
}) => {
|
|
||||||
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
||||||
|
const sidebarDocs = useAtomValue(sidebarSelectedDocumentsAtom);
|
||||||
|
const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom);
|
||||||
|
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||||
|
|
||||||
// Check if composer text is empty (chips are represented in mentionedDocuments atom)
|
|
||||||
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;
|
||||||
});
|
});
|
||||||
const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0;
|
const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0;
|
||||||
|
|
||||||
// Check if a model is configured
|
|
||||||
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
||||||
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
||||||
const { data: preferences } = useAtomValue(llmPreferencesAtom);
|
const { data: preferences } = useAtomValue(llmPreferencesAtom);
|
||||||
|
|
@ -770,121 +492,91 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
||||||
const agentLlmId = preferences.agent_llm_id;
|
const agentLlmId = preferences.agent_llm_id;
|
||||||
if (agentLlmId === null || agentLlmId === undefined) return false;
|
if (agentLlmId === null || agentLlmId === undefined) return false;
|
||||||
|
|
||||||
// Check if the configured model actually exists
|
|
||||||
// Auto mode (ID 0) and global configs (negative IDs) are in globalConfigs
|
|
||||||
if (agentLlmId <= 0) {
|
if (agentLlmId <= 0) {
|
||||||
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||||
}
|
}
|
||||||
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||||
}, [preferences, globalConfigs, userConfigs]);
|
}, [preferences, globalConfigs, userConfigs]);
|
||||||
|
|
||||||
const isSendDisabled =
|
const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
|
||||||
isComposerEmpty ||
|
|
||||||
!hasModelConfigured ||
|
|
||||||
isBlockedByOtherUser ||
|
|
||||||
isUploadingDocs ||
|
|
||||||
blockingUploadedMentionsCount > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip={
|
tooltip="Upload"
|
||||||
isUploadingDocs ? (
|
|
||||||
"Uploading documents..."
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="font-medium">Upload and mention files</span>
|
|
||||||
<span className="text-xs text-muted-foreground flex items-center">
|
|
||||||
Max 10 files <Dot className="size-3" /> 50 MB each
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">Total upload limit: 200 MB</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
side="bottom"
|
side="bottom"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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"
|
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="Upload files"
|
aria-label="Upload documents"
|
||||||
onClick={onUploadClick}
|
onClick={openUploadDialog}
|
||||||
disabled={isUploadingDocs}
|
|
||||||
>
|
>
|
||||||
{isUploadingDocs ? (
|
<PlusIcon className="size-4" />
|
||||||
<Spinner size="sm" className="text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<Paperclip className="size-4" />
|
|
||||||
)}
|
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
<ConnectorIndicator />
|
<ConnectorIndicator />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{blockingUploadedMentionsCount > 0 && (
|
{!hasModelConfigured && (
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
|
||||||
{hasFailedUploadedMentions ? <FileWarning className="size-3" /> : <Spinner size="xs" />}
|
|
||||||
<span>
|
|
||||||
{hasFailedUploadedMentions
|
|
||||||
? "Remove or retry failed uploads"
|
|
||||||
: "Waiting for uploaded files to finish indexing"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Show warning when no model is configured */}
|
|
||||||
{!hasModelConfigured && blockingUploadedMentionsCount === 0 && (
|
|
||||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
||||||
<AlertCircle className="size-3" />
|
<AlertCircle className="size-3" />
|
||||||
<span>Select a model</span>
|
<span>Select a model</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
<div className="flex items-center gap-2">
|
||||||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
{sidebarDocs.length > 0 && (
|
||||||
<TooltipIconButton
|
<button
|
||||||
tooltip={
|
|
||||||
isBlockedByOtherUser
|
|
||||||
? "Wait for AI to finish responding"
|
|
||||||
: hasFailedUploadedMentions
|
|
||||||
? "Remove or retry failed uploads before sending"
|
|
||||||
: blockingUploadedMentionsCount > 0
|
|
||||||
? "Waiting for uploaded files to finish indexing"
|
|
||||||
: isUploadingDocs
|
|
||||||
? "Uploading documents..."
|
|
||||||
: !hasModelConfigured
|
|
||||||
? "Please select a model from the header to start chatting"
|
|
||||||
: isComposerEmpty
|
|
||||||
? "Enter a message to send"
|
|
||||||
: "Send message"
|
|
||||||
}
|
|
||||||
side="bottom"
|
|
||||||
type="submit"
|
|
||||||
variant="default"
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
"aui-composer-send size-8 rounded-full",
|
|
||||||
isSendDisabled && "cursor-not-allowed opacity-50"
|
|
||||||
)}
|
|
||||||
aria-label="Send message"
|
|
||||||
disabled={isSendDisabled}
|
|
||||||
>
|
|
||||||
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
|
||||||
</TooltipIconButton>
|
|
||||||
</ComposerPrimitive.Send>
|
|
||||||
</AssistantIf>
|
|
||||||
|
|
||||||
<AssistantIf condition={({ thread }) => thread.isRunning}>
|
|
||||||
<ComposerPrimitive.Cancel asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
type="button"
|
||||||
variant="default"
|
onClick={() => setDocumentsSidebarOpen(true)}
|
||||||
size="icon"
|
className="rounded-full border border-border/60 bg-accent/50 px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-accent"
|
||||||
className="aui-composer-cancel size-8 rounded-full"
|
|
||||||
aria-label="Stop generating"
|
|
||||||
>
|
>
|
||||||
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
{sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
|
||||||
</Button>
|
</button>
|
||||||
</ComposerPrimitive.Cancel>
|
)}
|
||||||
</AssistantIf>
|
|
||||||
|
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
||||||
|
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||||
|
<TooltipIconButton
|
||||||
|
tooltip={
|
||||||
|
isBlockedByOtherUser
|
||||||
|
? "Wait for AI to finish responding"
|
||||||
|
: !hasModelConfigured
|
||||||
|
? "Please select a model from the header to start chatting"
|
||||||
|
: isComposerEmpty
|
||||||
|
? "Enter a message to send"
|
||||||
|
: "Send message"
|
||||||
|
}
|
||||||
|
side="bottom"
|
||||||
|
type="submit"
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"aui-composer-send size-8 rounded-full",
|
||||||
|
isSendDisabled && "cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
aria-label="Send message"
|
||||||
|
disabled={isSendDisabled}
|
||||||
|
>
|
||||||
|
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
||||||
|
</TooltipIconButton>
|
||||||
|
</ComposerPrimitive.Send>
|
||||||
|
</AssistantIf>
|
||||||
|
|
||||||
|
<AssistantIf condition={({ thread }) => thread.isRunning}>
|
||||||
|
<ComposerPrimitive.Cancel asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
className="aui-composer-cancel size-8 rounded-full"
|
||||||
|
aria-label="Stop generating"
|
||||||
|
>
|
||||||
|
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
||||||
|
</Button>
|
||||||
|
</ComposerPrimitive.Cancel>
|
||||||
|
</AssistantIf>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
|
||||||
)}
|
)}
|
||||||
{canEdit && canDelete && <DropdownMenuSeparator />}
|
{canEdit && canDelete && <DropdownMenuSeparator />}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
<DropdownMenuItem onClick={onDelete}>
|
||||||
<Trash2 className="mr-2 size-4" />
|
<Trash2 className="mr-2 size-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from "@/components/ui/breadcrumb";
|
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
|
||||||
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
|
|
||||||
import { getThreadFull } from "@/lib/chat/thread-persistence";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
|
|
||||||
interface BreadcrumbItemInterface {
|
|
||||||
label: string;
|
|
||||||
href?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardBreadcrumb() {
|
|
||||||
const t = useTranslations("breadcrumb");
|
|
||||||
const pathname = usePathname();
|
|
||||||
// Extract search space ID and chat ID from pathname
|
|
||||||
const segments = pathname.split("/").filter(Boolean);
|
|
||||||
const searchSpaceId = segments[0] === "dashboard" && segments[1] ? segments[1] : null;
|
|
||||||
|
|
||||||
const { data: searchSpace } = useQuery({
|
|
||||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId || ""),
|
|
||||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
|
||||||
enabled: !!searchSpaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract chat thread ID from pathname for chat pages
|
|
||||||
const chatThreadId = segments[2] === "new-chat" && segments[3] ? segments[3] : null;
|
|
||||||
|
|
||||||
// Fetch thread details when on a chat page with a thread ID
|
|
||||||
const { data: threadData } = useQuery({
|
|
||||||
queryKey: ["threads", searchSpaceId, "detail", chatThreadId],
|
|
||||||
queryFn: () => getThreadFull(Number(chatThreadId)),
|
|
||||||
enabled: !!chatThreadId && !!searchSpaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// State to store document title for editor breadcrumb
|
|
||||||
const [documentTitle, setDocumentTitle] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Fetch document title when on editor page
|
|
||||||
useEffect(() => {
|
|
||||||
if (segments[2] === "editor" && segments[3] && searchSpaceId) {
|
|
||||||
const documentId = segments[3];
|
|
||||||
|
|
||||||
// Skip fetch for "new" notes
|
|
||||||
if (documentId === "new") {
|
|
||||||
setDocumentTitle(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = getBearerToken();
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
|
|
||||||
{ method: "GET" }
|
|
||||||
)
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (data.title) {
|
|
||||||
setDocumentTitle(data.title);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// If fetch fails, just use the document ID
|
|
||||||
setDocumentTitle(null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setDocumentTitle(null);
|
|
||||||
}
|
|
||||||
}, [segments, searchSpaceId]);
|
|
||||||
|
|
||||||
// Parse the pathname to create breadcrumb items
|
|
||||||
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
|
|
||||||
const segments = path.split("/").filter(Boolean);
|
|
||||||
const breadcrumbs: BreadcrumbItemInterface[] = [];
|
|
||||||
|
|
||||||
// Handle search space (start directly with search space, skip "Dashboard")
|
|
||||||
if (segments[0] === "dashboard" && segments[1]) {
|
|
||||||
// Use the actual search space name if available, otherwise fall back to the ID
|
|
||||||
const searchSpaceLabel = searchSpace?.name || `${t("search_space")} ${segments[1]}`;
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: searchSpaceLabel,
|
|
||||||
href: `/dashboard/${segments[1]}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle specific sections
|
|
||||||
if (segments[2]) {
|
|
||||||
const section = segments[2];
|
|
||||||
let sectionLabel = section.charAt(0).toUpperCase() + section.slice(1);
|
|
||||||
|
|
||||||
// Map section names to more readable labels
|
|
||||||
const sectionLabels: Record<string, string> = {
|
|
||||||
"new-chat": t("chat") || "Chat",
|
|
||||||
documents: t("documents"),
|
|
||||||
logs: t("logs"),
|
|
||||||
settings: t("settings"),
|
|
||||||
editor: t("editor"),
|
|
||||||
};
|
|
||||||
|
|
||||||
sectionLabel = sectionLabels[section] || sectionLabel;
|
|
||||||
|
|
||||||
// Handle sub-sections
|
|
||||||
if (segments[3]) {
|
|
||||||
const subSection = segments[3];
|
|
||||||
|
|
||||||
// Handle editor sub-sections (document ID)
|
|
||||||
if (section === "editor") {
|
|
||||||
// Handle special cases for editor
|
|
||||||
let documentLabel: string;
|
|
||||||
if (subSection === "new") {
|
|
||||||
documentLabel = "New Note";
|
|
||||||
} else {
|
|
||||||
documentLabel = documentTitle || subSection;
|
|
||||||
}
|
|
||||||
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: t("documents"),
|
|
||||||
href: `/dashboard/${segments[1]}/documents`,
|
|
||||||
});
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: sectionLabel,
|
|
||||||
href: `/dashboard/${segments[1]}/documents`,
|
|
||||||
});
|
|
||||||
breadcrumbs.push({ label: documentLabel });
|
|
||||||
return breadcrumbs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle documents sub-sections
|
|
||||||
if (section === "documents") {
|
|
||||||
const documentLabels: Record<string, string> = {
|
|
||||||
upload: t("upload_documents"),
|
|
||||||
webpage: t("add_webpages"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const documentLabel = documentLabels[subSection] || subSection;
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: t("documents"),
|
|
||||||
href: `/dashboard/${segments[1]}/documents`,
|
|
||||||
});
|
|
||||||
breadcrumbs.push({ label: documentLabel });
|
|
||||||
return breadcrumbs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle new-chat sub-sections (thread IDs)
|
|
||||||
// Show the chat title if available, otherwise fall back to "Chat"
|
|
||||||
if (section === "new-chat") {
|
|
||||||
const chatLabel = threadData?.title || t("chat") || "Chat";
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: chatLabel,
|
|
||||||
});
|
|
||||||
return breadcrumbs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle other sub-sections
|
|
||||||
let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
|
|
||||||
const subSectionLabels: Record<string, string> = {
|
|
||||||
upload: t("upload_documents"),
|
|
||||||
youtube: t("add_youtube"),
|
|
||||||
webpage: t("add_webpages"),
|
|
||||||
manage: t("manage"),
|
|
||||||
};
|
|
||||||
|
|
||||||
subSectionLabel = subSectionLabels[subSection] || subSectionLabel;
|
|
||||||
|
|
||||||
breadcrumbs.push({
|
|
||||||
label: sectionLabel,
|
|
||||||
href: `/dashboard/${segments[1]}/${section}`,
|
|
||||||
});
|
|
||||||
breadcrumbs.push({ label: subSectionLabel });
|
|
||||||
} else {
|
|
||||||
breadcrumbs.push({ label: sectionLabel });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return breadcrumbs;
|
|
||||||
};
|
|
||||||
|
|
||||||
const breadcrumbs = generateBreadcrumbs(pathname);
|
|
||||||
|
|
||||||
if (breadcrumbs.length === 0) {
|
|
||||||
return null; // Don't show breadcrumbs for root dashboard
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Breadcrumb className="select-none">
|
|
||||||
<BreadcrumbList>
|
|
||||||
{breadcrumbs.map((item, index) => (
|
|
||||||
<React.Fragment key={`${index}-${item.href || item.label}`}>
|
|
||||||
<BreadcrumbItem>
|
|
||||||
{index === breadcrumbs.length - 1 ? (
|
|
||||||
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
|
||||||
) : (
|
|
||||||
<BreadcrumbLink href={item.href}>{item.label}</BreadcrumbLink>
|
|
||||||
)}
|
|
||||||
</BreadcrumbItem>
|
|
||||||
{index < breadcrumbs.length - 1 && <BreadcrumbSeparator />}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -76,7 +76,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa
|
||||||
<SelectTrigger id="param-key" className="w-full">
|
<SelectTrigger id="param-key" className="w-full">
|
||||||
<SelectValue placeholder="Select parameter" />
|
<SelectValue placeholder="Select parameter" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="bg-muted dark:border-neutral-700">
|
||||||
{PARAM_KEYS.map((key) => (
|
{PARAM_KEYS.map((key) => (
|
||||||
<SelectItem key={key} value={key}>
|
<SelectItem key={key} value={key}>
|
||||||
{key}
|
{key}
|
||||||
|
|
@ -104,7 +104,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={!selectedKey || value === ""}
|
disabled={!selectedKey || value === ""}
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" /> Add Parameter
|
Add Parameter
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,28 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import {
|
import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
|
||||||
AlertTriangle,
|
|
||||||
Inbox,
|
|
||||||
LogOut,
|
|
||||||
Megaphone,
|
|
||||||
PencilIcon,
|
|
||||||
SquareLibrary,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
|
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||||
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -32,6 +35,7 @@ import {
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
||||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||||
|
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
|
||||||
import { useInbox } from "@/hooks/use-inbox";
|
import { useInbox } from "@/hooks/use-inbox";
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { logout } from "@/lib/auth-utils";
|
import { logout } from "@/lib/auth-utils";
|
||||||
|
|
@ -46,7 +50,6 @@ import { LayoutShell } from "../ui/shell";
|
||||||
interface LayoutDataProviderProps {
|
interface LayoutDataProviderProps {
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
breadcrumb?: React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -60,11 +63,7 @@ function formatInboxCount(count: number): string {
|
||||||
return `${thousands}k+`;
|
return `${thousands}k+`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutDataProvider({
|
export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProviderProps) {
|
||||||
searchSpaceId,
|
|
||||||
children,
|
|
||||||
breadcrumb,
|
|
||||||
}: LayoutDataProviderProps) {
|
|
||||||
const t = useTranslations("dashboard");
|
const t = useTranslations("dashboard");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const tSidebar = useTranslations("sidebar");
|
const tSidebar = useTranslations("sidebar");
|
||||||
|
|
@ -114,40 +113,27 @@ export function LayoutDataProvider({
|
||||||
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
||||||
const [isInboxDocked, setIsInboxDocked] = useState(false);
|
const [isInboxDocked, setIsInboxDocked] = useState(false);
|
||||||
|
|
||||||
|
// Documents sidebar state (shared atom so Composer can toggle it)
|
||||||
|
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
|
||||||
|
|
||||||
// Announcements sidebar state
|
// Announcements sidebar state
|
||||||
const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false);
|
const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false);
|
||||||
|
|
||||||
// Search space dialog state
|
// Search space dialog state
|
||||||
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
||||||
|
|
||||||
// Inbox hooks - separate data sources for mentions and status tabs
|
// Per-tab inbox hooks — each has independent API loading, pagination,
|
||||||
// This ensures each tab has independent pagination and data loading
|
// and Electric live queries. The Electric sync shape is shared (client-level cache).
|
||||||
const userId = user?.id ? String(user.id) : null;
|
const userId = user?.id ? String(user.id) : null;
|
||||||
|
const numericSpaceId = Number(searchSpaceId) || null;
|
||||||
|
|
||||||
const {
|
const commentsInbox = useInbox(userId, numericSpaceId, "comments");
|
||||||
inboxItems: mentionItems,
|
const statusInbox = useInbox(userId, numericSpaceId, "status");
|
||||||
unreadCount: mentionUnreadCount,
|
|
||||||
loading: mentionLoading,
|
|
||||||
loadingMore: mentionLoadingMore,
|
|
||||||
hasMore: mentionHasMore,
|
|
||||||
loadMore: mentionLoadMore,
|
|
||||||
markAsRead: markMentionAsRead,
|
|
||||||
markAllAsRead: markAllMentionsAsRead,
|
|
||||||
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
|
|
||||||
|
|
||||||
const {
|
const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount;
|
||||||
inboxItems: statusItems,
|
|
||||||
unreadCount: allUnreadCount,
|
|
||||||
loading: statusLoading,
|
|
||||||
loadingMore: statusLoadingMore,
|
|
||||||
hasMore: statusHasMore,
|
|
||||||
loadMore: statusLoadMore,
|
|
||||||
markAsRead: markStatusAsRead,
|
|
||||||
markAllAsRead: markAllStatusAsRead,
|
|
||||||
} = useInbox(userId, Number(searchSpaceId) || null, null);
|
|
||||||
|
|
||||||
const totalUnreadCount = allUnreadCount;
|
// Whether any documents are currently being uploaded/indexed — drives sidebar spinner
|
||||||
const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount);
|
const isDocumentsProcessing = useDocumentsProcessing(numericSpaceId);
|
||||||
|
|
||||||
// Track seen notification IDs to detect new page_limit_exceeded notifications
|
// Track seen notification IDs to detect new page_limit_exceeded notifications
|
||||||
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
||||||
|
|
@ -155,14 +141,12 @@ export function LayoutDataProvider({
|
||||||
|
|
||||||
// Effect to show toast for new page_limit_exceeded notifications
|
// Effect to show toast for new page_limit_exceeded notifications
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statusLoading) return;
|
if (statusInbox.loading) return;
|
||||||
|
|
||||||
// Get page_limit_exceeded notifications
|
const pageLimitNotifications = statusInbox.inboxItems.filter(
|
||||||
const pageLimitNotifications = statusItems.filter(
|
|
||||||
(item) => item.type === "page_limit_exceeded"
|
(item) => item.type === "page_limit_exceeded"
|
||||||
);
|
);
|
||||||
|
|
||||||
// On initial load, just mark all as seen without showing toasts
|
|
||||||
if (isInitialLoad.current) {
|
if (isInitialLoad.current) {
|
||||||
for (const notification of pageLimitNotifications) {
|
for (const notification of pageLimitNotifications) {
|
||||||
seenPageLimitNotifications.current.add(notification.id);
|
seenPageLimitNotifications.current.add(notification.id);
|
||||||
|
|
@ -171,16 +155,13 @@ export function LayoutDataProvider({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find new notifications (not yet seen)
|
|
||||||
const newNotifications = pageLimitNotifications.filter(
|
const newNotifications = pageLimitNotifications.filter(
|
||||||
(notification) => !seenPageLimitNotifications.current.has(notification.id)
|
(notification) => !seenPageLimitNotifications.current.has(notification.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show toast for each new page_limit_exceeded notification
|
|
||||||
for (const notification of newNotifications) {
|
for (const notification of newNotifications) {
|
||||||
seenPageLimitNotifications.current.add(notification.id);
|
seenPageLimitNotifications.current.add(notification.id);
|
||||||
|
|
||||||
// Extract metadata for navigation
|
|
||||||
const actionUrl = isPageLimitExceededMetadata(notification.metadata)
|
const actionUrl = isPageLimitExceededMetadata(notification.metadata)
|
||||||
? notification.metadata.action_url
|
? notification.metadata.action_url
|
||||||
: `/dashboard/${searchSpaceId}/more-pages`;
|
: `/dashboard/${searchSpaceId}/more-pages`;
|
||||||
|
|
@ -195,24 +176,7 @@ export function LayoutDataProvider({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [statusItems, statusLoading, searchSpaceId, router]);
|
}, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]);
|
||||||
|
|
||||||
// Unified mark as read that delegates to the correct hook
|
|
||||||
const markAsRead = useCallback(
|
|
||||||
async (id: number) => {
|
|
||||||
// Try both - one will succeed based on which list has the item
|
|
||||||
const mentionResult = await markMentionAsRead(id);
|
|
||||||
if (mentionResult) return true;
|
|
||||||
return markStatusAsRead(id);
|
|
||||||
},
|
|
||||||
[markMentionAsRead, markStatusAsRead]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mark all as read for both types
|
|
||||||
const markAllAsRead = useCallback(async () => {
|
|
||||||
await Promise.all([markAllMentionsAsRead(), markAllStatusAsRead()]);
|
|
||||||
return true;
|
|
||||||
}, [markAllMentionsAsRead, markAllStatusAsRead]);
|
|
||||||
|
|
||||||
// Delete dialogs state
|
// Delete dialogs state
|
||||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||||
|
|
@ -295,34 +259,35 @@ export function LayoutDataProvider({
|
||||||
// Navigation items
|
// Navigation items
|
||||||
const navItems: NavItem[] = useMemo(
|
const navItems: NavItem[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
|
||||||
title: "Documents",
|
|
||||||
url: `/dashboard/${searchSpaceId}/documents`,
|
|
||||||
icon: SquareLibrary,
|
|
||||||
isActive: pathname?.includes("/documents"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Inbox",
|
title: "Inbox",
|
||||||
url: "#inbox", // Special URL to indicate this is handled differently
|
url: "#inbox",
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
isActive: isInboxSidebarOpen,
|
isActive: isInboxSidebarOpen,
|
||||||
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
|
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Documents",
|
||||||
|
url: "#documents",
|
||||||
|
icon: SquareLibrary,
|
||||||
|
isActive: isDocumentsSidebarOpen,
|
||||||
|
showSpinner: isDocumentsProcessing,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Announcements",
|
title: "Announcements",
|
||||||
url: "#announcements", // Special URL to indicate this is handled differently
|
url: "#announcements",
|
||||||
icon: Megaphone,
|
icon: Megaphone,
|
||||||
isActive: isAnnouncementsSidebarOpen,
|
isActive: isAnnouncementsSidebarOpen,
|
||||||
badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
|
badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
searchSpaceId,
|
|
||||||
pathname,
|
|
||||||
isInboxSidebarOpen,
|
isInboxSidebarOpen,
|
||||||
|
isDocumentsSidebarOpen,
|
||||||
totalUnreadCount,
|
totalUnreadCount,
|
||||||
isAnnouncementsSidebarOpen,
|
isAnnouncementsSidebarOpen,
|
||||||
announcementUnreadCount,
|
announcementUnreadCount,
|
||||||
|
isDocumentsProcessing,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -415,10 +380,22 @@ export function LayoutDataProvider({
|
||||||
|
|
||||||
const handleNavItemClick = useCallback(
|
const handleNavItemClick = useCallback(
|
||||||
(item: NavItem) => {
|
(item: NavItem) => {
|
||||||
// Handle inbox specially - toggle sidebar instead of navigating
|
|
||||||
if (item.url === "#inbox") {
|
if (item.url === "#inbox") {
|
||||||
setIsInboxSidebarOpen((prev) => {
|
setIsInboxSidebarOpen((prev) => {
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
|
setIsDocumentsSidebarOpen(false);
|
||||||
|
setIsAnnouncementsSidebarOpen(false);
|
||||||
|
}
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.url === "#documents") {
|
||||||
|
setIsDocumentsSidebarOpen((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
setIsInboxSidebarOpen(false);
|
||||||
setIsAllSharedChatsSidebarOpen(false);
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
setIsAllPrivateChatsSidebarOpen(false);
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
setIsAnnouncementsSidebarOpen(false);
|
setIsAnnouncementsSidebarOpen(false);
|
||||||
|
|
@ -427,13 +404,13 @@ export function LayoutDataProvider({
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Handle announcements specially - toggle sidebar instead of navigating
|
|
||||||
if (item.url === "#announcements") {
|
if (item.url === "#announcements") {
|
||||||
setIsAnnouncementsSidebarOpen((prev) => {
|
setIsAnnouncementsSidebarOpen((prev) => {
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
setIsInboxSidebarOpen(false);
|
setIsInboxSidebarOpen(false);
|
||||||
setIsAllSharedChatsSidebarOpen(false);
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
setIsAllPrivateChatsSidebarOpen(false);
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
|
setIsDocumentsSidebarOpen(false);
|
||||||
}
|
}
|
||||||
return !prev;
|
return !prev;
|
||||||
});
|
});
|
||||||
|
|
@ -441,13 +418,7 @@ export function LayoutDataProvider({
|
||||||
}
|
}
|
||||||
router.push(item.url);
|
router.push(item.url);
|
||||||
},
|
},
|
||||||
[
|
[router, setIsDocumentsSidebarOpen]
|
||||||
router,
|
|
||||||
setIsAllPrivateChatsSidebarOpen,
|
|
||||||
setIsAllSharedChatsSidebarOpen,
|
|
||||||
setIsAnnouncementsSidebarOpen,
|
|
||||||
setIsInboxSidebarOpen,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNewChat = useCallback(() => {
|
const handleNewChat = useCallback(() => {
|
||||||
|
|
@ -544,15 +515,17 @@ export function LayoutDataProvider({
|
||||||
setIsAllSharedChatsSidebarOpen(true);
|
setIsAllSharedChatsSidebarOpen(true);
|
||||||
setIsAllPrivateChatsSidebarOpen(false);
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
setIsInboxSidebarOpen(false);
|
setIsInboxSidebarOpen(false);
|
||||||
|
setIsDocumentsSidebarOpen(false);
|
||||||
setIsAnnouncementsSidebarOpen(false);
|
setIsAnnouncementsSidebarOpen(false);
|
||||||
}, []);
|
}, [setIsDocumentsSidebarOpen]);
|
||||||
|
|
||||||
const handleViewAllPrivateChats = useCallback(() => {
|
const handleViewAllPrivateChats = useCallback(() => {
|
||||||
setIsAllPrivateChatsSidebarOpen(true);
|
setIsAllPrivateChatsSidebarOpen(true);
|
||||||
setIsAllSharedChatsSidebarOpen(false);
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
setIsInboxSidebarOpen(false);
|
setIsInboxSidebarOpen(false);
|
||||||
|
setIsDocumentsSidebarOpen(false);
|
||||||
setIsAnnouncementsSidebarOpen(false);
|
setIsAnnouncementsSidebarOpen(false);
|
||||||
}, []);
|
}, [setIsDocumentsSidebarOpen]);
|
||||||
|
|
||||||
// Delete handlers
|
// Delete handlers
|
||||||
const confirmDeleteChat = useCallback(async () => {
|
const confirmDeleteChat = useCallback(async () => {
|
||||||
|
|
@ -583,10 +556,6 @@ export function LayoutDataProvider({
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||||
// Invalidate thread detail for breadcrumb update
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error renaming thread:", error);
|
console.error("Error renaming thread:", error);
|
||||||
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
|
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
|
||||||
|
|
@ -641,7 +610,6 @@ export function LayoutDataProvider({
|
||||||
onUserSettings={handleUserSettings}
|
onUserSettings={handleUserSettings}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
breadcrumb={breadcrumb}
|
|
||||||
theme={theme}
|
theme={theme}
|
||||||
setTheme={setTheme}
|
setTheme={setTheme}
|
||||||
isChatPage={isChatPage}
|
isChatPage={isChatPage}
|
||||||
|
|
@ -649,26 +617,27 @@ export function LayoutDataProvider({
|
||||||
inbox={{
|
inbox={{
|
||||||
isOpen: isInboxSidebarOpen,
|
isOpen: isInboxSidebarOpen,
|
||||||
onOpenChange: setIsInboxSidebarOpen,
|
onOpenChange: setIsInboxSidebarOpen,
|
||||||
// Separate data sources for each tab
|
totalUnreadCount,
|
||||||
mentions: {
|
comments: {
|
||||||
items: mentionItems,
|
items: commentsInbox.inboxItems,
|
||||||
unreadCount: mentionUnreadCount,
|
unreadCount: commentsInbox.unreadCount,
|
||||||
loading: mentionLoading,
|
loading: commentsInbox.loading,
|
||||||
loadingMore: mentionLoadingMore,
|
loadingMore: commentsInbox.loadingMore,
|
||||||
hasMore: mentionHasMore,
|
hasMore: commentsInbox.hasMore,
|
||||||
loadMore: mentionLoadMore,
|
loadMore: commentsInbox.loadMore,
|
||||||
|
markAsRead: commentsInbox.markAsRead,
|
||||||
|
markAllAsRead: commentsInbox.markAllAsRead,
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
items: statusItems,
|
items: statusInbox.inboxItems,
|
||||||
unreadCount: statusOnlyUnreadCount,
|
unreadCount: statusInbox.unreadCount,
|
||||||
loading: statusLoading,
|
loading: statusInbox.loading,
|
||||||
loadingMore: statusLoadingMore,
|
loadingMore: statusInbox.loadingMore,
|
||||||
hasMore: statusHasMore,
|
hasMore: statusInbox.hasMore,
|
||||||
loadMore: statusLoadMore,
|
loadMore: statusInbox.loadMore,
|
||||||
|
markAsRead: statusInbox.markAsRead,
|
||||||
|
markAllAsRead: statusInbox.markAllAsRead,
|
||||||
},
|
},
|
||||||
totalUnreadCount,
|
|
||||||
markAsRead,
|
|
||||||
markAllAsRead,
|
|
||||||
isDocked: isInboxDocked,
|
isDocked: isInboxDocked,
|
||||||
onDockedChange: setIsInboxDocked,
|
onDockedChange: setIsInboxDocked,
|
||||||
}}
|
}}
|
||||||
|
|
@ -686,36 +655,33 @@ export function LayoutDataProvider({
|
||||||
onOpenChange: setIsAllPrivateChatsSidebarOpen,
|
onOpenChange: setIsAllPrivateChatsSidebarOpen,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
}}
|
}}
|
||||||
|
documentsPanel={{
|
||||||
|
open: isDocumentsSidebarOpen,
|
||||||
|
onOpenChange: setIsDocumentsSidebarOpen,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</LayoutShell>
|
</LayoutShell>
|
||||||
|
|
||||||
{/* Delete Chat Dialog */}
|
{/* Delete Chat Dialog */}
|
||||||
<Dialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}>
|
<AlertDialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<AlertDialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<AlertDialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<AlertDialogTitle>{t("delete_chat")}</AlertDialogTitle>
|
||||||
<Trash2 className="h-5 w-5 text-destructive" />
|
<AlertDialogDescription>
|
||||||
<span>{t("delete_chat")}</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "}
|
{t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "}
|
||||||
{t("action_cannot_undone")}
|
{t("action_cannot_undone")}
|
||||||
</DialogDescription>
|
</AlertDialogDescription>
|
||||||
</DialogHeader>
|
</AlertDialogHeader>
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<AlertDialogFooter>
|
||||||
<Button
|
<AlertDialogCancel disabled={isDeletingChat}>{tCommon("cancel")}</AlertDialogCancel>
|
||||||
variant="outline"
|
<AlertDialogAction
|
||||||
onClick={() => setShowDeleteChatDialog(false)}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
confirmDeleteChat();
|
||||||
|
}}
|
||||||
disabled={isDeletingChat}
|
disabled={isDeletingChat}
|
||||||
>
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||||
{tCommon("cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={confirmDeleteChat}
|
|
||||||
disabled={isDeletingChat}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
>
|
||||||
{isDeletingChat ? (
|
{isDeletingChat ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -723,15 +689,12 @@ export function LayoutDataProvider({
|
||||||
{t("deleting")}
|
{t("deleting")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
tCommon("delete")
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
{tCommon("delete")}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</AlertDialogAction>
|
||||||
</DialogFooter>
|
</AlertDialogFooter>
|
||||||
</DialogContent>
|
</AlertDialogContent>
|
||||||
</Dialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Rename Chat Dialog */}
|
{/* Rename Chat Dialog */}
|
||||||
<Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}>
|
<Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}>
|
||||||
|
|
@ -756,7 +719,7 @@ export function LayoutDataProvider({
|
||||||
/>
|
/>
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
onClick={() => setShowRenameChatDialog(false)}
|
onClick={() => setShowRenameChatDialog(false)}
|
||||||
disabled={isRenamingChat}
|
disabled={isRenamingChat}
|
||||||
>
|
>
|
||||||
|
|
@ -773,10 +736,7 @@ export function LayoutDataProvider({
|
||||||
{tSidebar("renaming") || "Renaming"}
|
{tSidebar("renaming") || "Renaming"}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
tSidebar("rename") || "Rename"
|
||||||
<PencilIcon className="h-4 w-4" />
|
|
||||||
{tSidebar("rename") || "Rename"}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
@ -784,30 +744,25 @@ export function LayoutDataProvider({
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete Search Space Dialog */}
|
{/* Delete Search Space Dialog */}
|
||||||
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
|
<AlertDialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<AlertDialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<AlertDialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<AlertDialogTitle>{t("delete_search_space")}</AlertDialogTitle>
|
||||||
<Trash2 className="h-5 w-5 text-destructive" />
|
<AlertDialogDescription>
|
||||||
<span>{t("delete_search_space")}</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })}
|
{t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })}
|
||||||
</DialogDescription>
|
</AlertDialogDescription>
|
||||||
</DialogHeader>
|
</AlertDialogHeader>
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<AlertDialogFooter>
|
||||||
<Button
|
<AlertDialogCancel disabled={isDeletingSearchSpace}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowDeleteSearchSpaceDialog(false)}
|
|
||||||
disabled={isDeletingSearchSpace}
|
|
||||||
>
|
|
||||||
{tCommon("cancel")}
|
{tCommon("cancel")}
|
||||||
</Button>
|
</AlertDialogCancel>
|
||||||
<Button
|
<AlertDialogAction
|
||||||
variant="destructive"
|
onClick={(e) => {
|
||||||
onClick={confirmDeleteSearchSpace}
|
e.preventDefault();
|
||||||
|
confirmDeleteSearchSpace();
|
||||||
|
}}
|
||||||
disabled={isDeletingSearchSpace}
|
disabled={isDeletingSearchSpace}
|
||||||
className="gap-2"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||||
>
|
>
|
||||||
{isDeletingSearchSpace ? (
|
{isDeletingSearchSpace ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -815,41 +770,33 @@ export function LayoutDataProvider({
|
||||||
{t("deleting")}
|
{t("deleting")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
tCommon("delete")
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
{tCommon("delete")}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</AlertDialogAction>
|
||||||
</DialogFooter>
|
</AlertDialogFooter>
|
||||||
</DialogContent>
|
</AlertDialogContent>
|
||||||
</Dialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Leave Search Space Dialog */}
|
{/* Leave Search Space Dialog */}
|
||||||
<Dialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
|
<AlertDialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<AlertDialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<AlertDialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<AlertDialogTitle>{t("leave_title")}</AlertDialogTitle>
|
||||||
<LogOut className="h-5 w-5 text-destructive" />
|
<AlertDialogDescription>
|
||||||
<span>{t("leave_title")}</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t("leave_confirm", { name: searchSpaceToLeave?.name || "" })}
|
{t("leave_confirm", { name: searchSpaceToLeave?.name || "" })}
|
||||||
</DialogDescription>
|
</AlertDialogDescription>
|
||||||
</DialogHeader>
|
</AlertDialogHeader>
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<AlertDialogFooter>
|
||||||
<Button
|
<AlertDialogCancel disabled={isLeavingSearchSpace}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowLeaveSearchSpaceDialog(false)}
|
|
||||||
disabled={isLeavingSearchSpace}
|
|
||||||
>
|
|
||||||
{tCommon("cancel")}
|
{tCommon("cancel")}
|
||||||
</Button>
|
</AlertDialogCancel>
|
||||||
<Button
|
<AlertDialogAction
|
||||||
variant="destructive"
|
onClick={(e) => {
|
||||||
onClick={confirmLeaveSearchSpace}
|
e.preventDefault();
|
||||||
|
confirmLeaveSearchSpace();
|
||||||
|
}}
|
||||||
disabled={isLeavingSearchSpace}
|
disabled={isLeavingSearchSpace}
|
||||||
className="gap-2"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||||
>
|
>
|
||||||
{isLeavingSearchSpace ? (
|
{isLeavingSearchSpace ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -857,15 +804,12 @@ export function LayoutDataProvider({
|
||||||
{t("leaving")}
|
{t("leaving")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
t("leave")
|
||||||
<LogOut className="h-4 w-4" />
|
|
||||||
{t("leave")}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</AlertDialogAction>
|
||||||
</DialogFooter>
|
</AlertDialogFooter>
|
||||||
</DialogContent>
|
</AlertDialogContent>
|
||||||
</Dialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Create Search Space Dialog */}
|
{/* Create Search Space Dialog */}
|
||||||
<CreateSearchSpaceDialog
|
<CreateSearchSpaceDialog
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export interface NavItem {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
badge?: string | number;
|
badge?: string | number;
|
||||||
|
showSpinner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatItem {
|
export interface ChatItem {
|
||||||
|
|
|
||||||
|
|
@ -138,20 +138,20 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter className="flex-row gap-2 pt-2 sm:pt-3">
|
<DialogFooter className="flex-row justify-end gap-2 pt-2 sm:pt-3">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
onClick={() => handleOpenChange(false)}
|
onClick={() => handleOpenChange(false)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex-1 sm:flex-none sm:w-auto h-8 sm:h-10 text-xs sm:text-sm"
|
className="h-8 sm:h-9 text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
{tCommon("cancel")}
|
{tCommon("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex-1 sm:flex-none sm:w-auto h-8 sm:h-10 text-xs sm:text-sm"
|
className="h-8 sm:h-9 text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -3,33 +3,30 @@
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
|
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
breadcrumb?: React.ReactNode;
|
|
||||||
mobileMenuTrigger?: React.ReactNode;
|
mobileMenuTrigger?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
|
export function Header({ mobileMenuTrigger }: HeaderProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
|
|
||||||
// Check if we're on a chat page
|
|
||||||
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||||
|
|
||||||
// Use Jotai atom for thread state (synced from chat page)
|
|
||||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||||
|
|
||||||
// Show button only when we have a thread id (thread exists and is synced to Jotai)
|
|
||||||
const hasThread = isChatPage && currentThreadState.id !== null;
|
const hasThread = isChatPage && currentThreadState.id !== null;
|
||||||
|
|
||||||
// Create minimal thread object for ChatShareButton (used for API calls)
|
|
||||||
const threadForButton: ThreadRecord | null =
|
const threadForButton: ThreadRecord | null =
|
||||||
hasThread && currentThreadState.id !== null
|
hasThread && currentThreadState.id !== null
|
||||||
? {
|
? {
|
||||||
id: currentThreadState.id,
|
id: currentThreadState.id,
|
||||||
visibility: currentThreadState.visibility ?? "PRIVATE",
|
visibility: currentThreadState.visibility ?? "PRIVATE",
|
||||||
// These fields are not used by ChatShareButton for display, only for checks
|
|
||||||
created_by_id: null,
|
created_by_id: null,
|
||||||
search_space_id: 0,
|
search_space_id: 0,
|
||||||
title: "",
|
title: "",
|
||||||
|
|
@ -39,22 +36,20 @@ export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleVisibilityChange = (_visibility: ChatVisibility) => {
|
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
|
||||||
// Visibility change is handled by ChatShareButton internally via Jotai
|
|
||||||
// This callback can be used for additional side effects if needed
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
|
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4 md:border-b md:border-border">
|
||||||
{/* Left side - Mobile menu trigger + Breadcrumb */}
|
{/* Left side - Mobile menu trigger + Model selector */}
|
||||||
<div className="flex flex-1 items-center gap-2 min-w-0">
|
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||||
{mobileMenuTrigger}
|
{mobileMenuTrigger}
|
||||||
<div className="hidden md:block">{breadcrumb}</div>
|
{isChatPage && searchSpaceId && (
|
||||||
|
<ChatHeader searchSpaceId={Number(searchSpaceId)} className="md:h-9 md:px-4 md:text-sm" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Actions */}
|
{/* Right side - Actions */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Share button - only show on chat pages when thread exists */}
|
|
||||||
{hasThread && (
|
{hasThread && (
|
||||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -159,13 +159,13 @@ export function SearchSpaceAvatar({
|
||||||
)}
|
)}
|
||||||
{onSettings && onDelete && <DropdownMenuSeparator />}
|
{onSettings && onDelete && <DropdownMenuSeparator />}
|
||||||
{onDelete && isOwner && (
|
{onDelete && isOwner && (
|
||||||
<DropdownMenuItem variant="destructive" onClick={onDelete}>
|
<DropdownMenuItem onClick={onDelete}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{tCommon("delete")}
|
{tCommon("delete")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{onDelete && !isOwner && (
|
{onDelete && !isOwner && (
|
||||||
<DropdownMenuItem variant="destructive" onClick={onDelete}>
|
<DropdownMenuItem onClick={onDelete}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{t("leave")}
|
{t("leave")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -217,13 +217,13 @@ export function SearchSpaceAvatar({
|
||||||
)}
|
)}
|
||||||
{onSettings && onDelete && <ContextMenuSeparator />}
|
{onSettings && onDelete && <ContextMenuSeparator />}
|
||||||
{onDelete && isOwner && (
|
{onDelete && isOwner && (
|
||||||
<ContextMenuItem variant="destructive" onClick={onDelete}>
|
<ContextMenuItem onClick={onDelete}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{tCommon("delete")}
|
{tCommon("delete")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
)}
|
||||||
{onDelete && !isOwner && (
|
{onDelete && !isOwner && (
|
||||||
<ContextMenuItem variant="destructive" onClick={onDelete}>
|
<ContextMenuItem onClick={onDelete}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{t("leave")}
|
{t("leave")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -14,37 +14,33 @@ import {
|
||||||
AllPrivateChatsSidebar,
|
AllPrivateChatsSidebar,
|
||||||
AllSharedChatsSidebar,
|
AllSharedChatsSidebar,
|
||||||
AnnouncementsSidebar,
|
AnnouncementsSidebar,
|
||||||
|
DocumentsSidebar,
|
||||||
InboxSidebar,
|
InboxSidebar,
|
||||||
MobileSidebar,
|
MobileSidebar,
|
||||||
MobileSidebarTrigger,
|
MobileSidebarTrigger,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
} from "../sidebar";
|
} from "../sidebar";
|
||||||
|
|
||||||
// Tab-specific data source props
|
// Per-tab data source
|
||||||
interface TabDataSource {
|
interface TabDataSource {
|
||||||
items: InboxItem[];
|
items: InboxItem[];
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
loadingMore?: boolean;
|
loadingMore: boolean;
|
||||||
hasMore?: boolean;
|
hasMore: boolean;
|
||||||
loadMore?: () => void;
|
loadMore: () => void;
|
||||||
|
markAsRead: (id: number) => Promise<boolean>;
|
||||||
|
markAllAsRead: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inbox-related props with separate data sources per tab
|
// Inbox-related props — per-tab data sources with independent loading/pagination
|
||||||
interface InboxProps {
|
interface InboxProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
/** Mentions tab data source with independent pagination */
|
|
||||||
mentions: TabDataSource;
|
|
||||||
/** Status tab data source with independent pagination */
|
|
||||||
status: TabDataSource;
|
|
||||||
/** Combined unread count for nav badge */
|
|
||||||
totalUnreadCount: number;
|
totalUnreadCount: number;
|
||||||
markAsRead: (id: number) => Promise<boolean>;
|
comments: TabDataSource;
|
||||||
markAllAsRead: () => Promise<boolean>;
|
status: TabDataSource;
|
||||||
/** Whether the inbox is docked (permanent) */
|
|
||||||
isDocked?: boolean;
|
isDocked?: boolean;
|
||||||
/** Callback to change docked state */
|
|
||||||
onDockedChange?: (docked: boolean) => void;
|
onDockedChange?: (docked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +70,6 @@ interface LayoutShellProps {
|
||||||
onUserSettings?: () => void;
|
onUserSettings?: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
pageUsage?: PageUsage;
|
pageUsage?: PageUsage;
|
||||||
breadcrumb?: React.ReactNode;
|
|
||||||
theme?: string;
|
theme?: string;
|
||||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||||
defaultCollapsed?: boolean;
|
defaultCollapsed?: boolean;
|
||||||
|
|
@ -99,6 +94,10 @@ interface LayoutShellProps {
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
};
|
};
|
||||||
|
documentsPanel?: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutShell({
|
export function LayoutShell({
|
||||||
|
|
@ -127,7 +126,6 @@ export function LayoutShell({
|
||||||
onUserSettings,
|
onUserSettings,
|
||||||
onLogout,
|
onLogout,
|
||||||
pageUsage,
|
pageUsage,
|
||||||
breadcrumb,
|
|
||||||
theme,
|
theme,
|
||||||
setTheme,
|
setTheme,
|
||||||
defaultCollapsed = false,
|
defaultCollapsed = false,
|
||||||
|
|
@ -139,6 +137,7 @@ export function LayoutShell({
|
||||||
isLoadingChats = false,
|
isLoadingChats = false,
|
||||||
allSharedChatsPanel,
|
allSharedChatsPanel,
|
||||||
allPrivateChatsPanel,
|
allPrivateChatsPanel,
|
||||||
|
documentsPanel,
|
||||||
}: LayoutShellProps) {
|
}: LayoutShellProps) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
@ -162,7 +161,6 @@ export function LayoutShell({
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
||||||
<Header
|
<Header
|
||||||
breadcrumb={breadcrumb}
|
|
||||||
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -208,16 +206,22 @@ export function LayoutShell({
|
||||||
<InboxSidebar
|
<InboxSidebar
|
||||||
open={inbox.isOpen}
|
open={inbox.isOpen}
|
||||||
onOpenChange={inbox.onOpenChange}
|
onOpenChange={inbox.onOpenChange}
|
||||||
mentions={inbox.mentions}
|
comments={inbox.comments}
|
||||||
status={inbox.status}
|
status={inbox.status}
|
||||||
totalUnreadCount={inbox.totalUnreadCount}
|
totalUnreadCount={inbox.totalUnreadCount}
|
||||||
markAsRead={inbox.markAsRead}
|
|
||||||
markAllAsRead={inbox.markAllAsRead}
|
|
||||||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Announcements Sidebar - only render when open to avoid scroll blocking */}
|
{/* Mobile Documents Sidebar - slide-out panel */}
|
||||||
|
{documentsPanel && (
|
||||||
|
<DocumentsSidebar
|
||||||
|
open={documentsPanel.open}
|
||||||
|
onOpenChange={documentsPanel.onOpenChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Announcements Sidebar */}
|
||||||
{announcementsPanel?.open && (
|
{announcementsPanel?.open && (
|
||||||
<AnnouncementsSidebar
|
<AnnouncementsSidebar
|
||||||
open={announcementsPanel.open}
|
open={announcementsPanel.open}
|
||||||
|
|
@ -307,18 +311,16 @@ export function LayoutShell({
|
||||||
<InboxSidebar
|
<InboxSidebar
|
||||||
open={inbox.isOpen}
|
open={inbox.isOpen}
|
||||||
onOpenChange={inbox.onOpenChange}
|
onOpenChange={inbox.onOpenChange}
|
||||||
mentions={inbox.mentions}
|
comments={inbox.comments}
|
||||||
status={inbox.status}
|
status={inbox.status}
|
||||||
totalUnreadCount={inbox.totalUnreadCount}
|
totalUnreadCount={inbox.totalUnreadCount}
|
||||||
markAsRead={inbox.markAsRead}
|
|
||||||
markAllAsRead={inbox.markAllAsRead}
|
|
||||||
isDocked={inbox.isDocked}
|
isDocked={inbox.isDocked}
|
||||||
onDockedChange={inbox.onDockedChange}
|
onDockedChange={inbox.onDockedChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
<Header breadcrumb={breadcrumb} />
|
<Header />
|
||||||
|
|
||||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -330,17 +332,23 @@ export function LayoutShell({
|
||||||
<InboxSidebar
|
<InboxSidebar
|
||||||
open={inbox.isOpen}
|
open={inbox.isOpen}
|
||||||
onOpenChange={inbox.onOpenChange}
|
onOpenChange={inbox.onOpenChange}
|
||||||
mentions={inbox.mentions}
|
comments={inbox.comments}
|
||||||
status={inbox.status}
|
status={inbox.status}
|
||||||
totalUnreadCount={inbox.totalUnreadCount}
|
totalUnreadCount={inbox.totalUnreadCount}
|
||||||
markAsRead={inbox.markAsRead}
|
|
||||||
markAllAsRead={inbox.markAllAsRead}
|
|
||||||
isDocked={false}
|
isDocked={false}
|
||||||
onDockedChange={inbox.onDockedChange}
|
onDockedChange={inbox.onDockedChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Announcements Sidebar - positioned absolutely on top of content */}
|
{/* Documents Sidebar - slide-out panel */}
|
||||||
|
{documentsPanel && (
|
||||||
|
<DocumentsSidebar
|
||||||
|
open={documentsPanel.open}
|
||||||
|
onOpenChange={documentsPanel.onOpenChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Announcements Sidebar */}
|
||||||
{announcementsPanel && (
|
{announcementsPanel && (
|
||||||
<AnnouncementsSidebar
|
<AnnouncementsSidebar
|
||||||
open={announcementsPanel.open}
|
open={announcementsPanel.open}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -40,6 +40,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import {
|
import {
|
||||||
deleteThread,
|
deleteThread,
|
||||||
|
|
@ -85,6 +86,15 @@ export function AllPrivateChatsSidebar({
|
||||||
const [isRenaming, setIsRenaming] = useState(false);
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||||
|
|
||||||
|
const pendingThreadIdRef = useRef<number | null>(null);
|
||||||
|
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
||||||
|
useCallback(() => {
|
||||||
|
if (pendingThreadIdRef.current !== null) {
|
||||||
|
setOpenDropdownId(pendingThreadIdRef.current);
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
const isSearchMode = !!debouncedSearchQuery.trim();
|
const isSearchMode = !!debouncedSearchQuery.trim();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -357,7 +367,16 @@ export function AllPrivateChatsSidebar({
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleThreadClick(thread.id)}
|
onClick={() => {
|
||||||
|
if (wasLongPress()) return;
|
||||||
|
handleThreadClick(thread.id);
|
||||||
|
}}
|
||||||
|
onTouchStart={() => {
|
||||||
|
pendingThreadIdRef.current = thread.id;
|
||||||
|
longPressHandlers.onTouchStart();
|
||||||
|
}}
|
||||||
|
onTouchEnd={longPressHandlers.onTouchEnd}
|
||||||
|
onTouchMove={longPressHandlers.onTouchMove}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
>
|
>
|
||||||
|
|
@ -396,7 +415,9 @@ export function AllPrivateChatsSidebar({
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 w-6 shrink-0",
|
"h-6 w-6 shrink-0",
|
||||||
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
isMobile
|
||||||
|
? "opacity-0 pointer-events-none absolute"
|
||||||
|
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
||||||
"transition-opacity"
|
"transition-opacity"
|
||||||
)}
|
)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
|
@ -435,10 +456,7 @@ export function AllPrivateChatsSidebar({
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
||||||
onClick={() => handleDeleteThread(thread.id)}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
<span>{t("delete") || "Delete"}</span>
|
<span>{t("delete") || "Delete"}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -496,7 +514,7 @@ export function AllPrivateChatsSidebar({
|
||||||
/>
|
/>
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
onClick={() => setShowRenameDialog(false)}
|
onClick={() => setShowRenameDialog(false)}
|
||||||
disabled={isRenaming}
|
disabled={isRenaming}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -40,6 +40,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import {
|
import {
|
||||||
deleteThread,
|
deleteThread,
|
||||||
|
|
@ -85,6 +86,15 @@ export function AllSharedChatsSidebar({
|
||||||
const [isRenaming, setIsRenaming] = useState(false);
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||||
|
|
||||||
|
const pendingThreadIdRef = useRef<number | null>(null);
|
||||||
|
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
||||||
|
useCallback(() => {
|
||||||
|
if (pendingThreadIdRef.current !== null) {
|
||||||
|
setOpenDropdownId(pendingThreadIdRef.current);
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
const isSearchMode = !!debouncedSearchQuery.trim();
|
const isSearchMode = !!debouncedSearchQuery.trim();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -357,7 +367,16 @@ export function AllSharedChatsSidebar({
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleThreadClick(thread.id)}
|
onClick={() => {
|
||||||
|
if (wasLongPress()) return;
|
||||||
|
handleThreadClick(thread.id);
|
||||||
|
}}
|
||||||
|
onTouchStart={() => {
|
||||||
|
pendingThreadIdRef.current = thread.id;
|
||||||
|
longPressHandlers.onTouchStart();
|
||||||
|
}}
|
||||||
|
onTouchEnd={longPressHandlers.onTouchEnd}
|
||||||
|
onTouchMove={longPressHandlers.onTouchMove}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
>
|
>
|
||||||
|
|
@ -396,7 +415,9 @@ export function AllSharedChatsSidebar({
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 w-6 shrink-0",
|
"h-6 w-6 shrink-0",
|
||||||
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
isMobile
|
||||||
|
? "opacity-0 pointer-events-none absolute"
|
||||||
|
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
||||||
"transition-opacity"
|
"transition-opacity"
|
||||||
)}
|
)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
|
@ -435,10 +456,7 @@ export function AllSharedChatsSidebar({
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
||||||
onClick={() => handleDeleteThread(thread.id)}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
<span>{t("delete") || "Delete"}</span>
|
<span>{t("delete") || "Delete"}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -496,7 +514,7 @@ export function AllSharedChatsSidebar({
|
||||||
/>
|
/>
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
onClick={() => setShowRenameDialog(false)}
|
onClick={() => setShowRenameDialog(false)}
|
||||||
disabled={isRenaming}
|
disabled={isRenaming}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
import { ChevronLeft } from "lucide-react";
|
import { ChevronLeft } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
|
|
||||||
import { AnnouncementCard } from "@/components/announcements/AnnouncementCard";
|
import { AnnouncementCard } from "@/components/announcements/AnnouncementCard";
|
||||||
|
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
|
@ -72,4 +72,3 @@ export function AnnouncementsSidebar({
|
||||||
</SidebarSlideOutPanel>
|
</SidebarSlideOutPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -17,6 +18,8 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ChatListItemProps {
|
interface ChatListItemProps {
|
||||||
|
|
@ -39,12 +42,24 @@ export function ChatListItem({
|
||||||
onDelete,
|
onDelete,
|
||||||
}: ChatListItemProps) {
|
}: ChatListItemProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
||||||
|
useCallback(() => setDropdownOpen(true), [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (wasLongPress()) return;
|
||||||
|
onClick?.();
|
||||||
|
}, [onClick, wasLongPress]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/item relative w-full">
|
<div className="group/item relative w-full">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={handleClick}
|
||||||
|
{...(isMobile ? longPressHandlers : {})}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
||||||
"[&>span:last-child]:truncate",
|
"[&>span:last-child]:truncate",
|
||||||
|
|
@ -57,9 +72,14 @@ export function ChatListItem({
|
||||||
<span className="w-[calc(100%-3rem)] ">{name}</span>
|
<span className="w-[calc(100%-3rem)] ">{name}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Actions dropdown */}
|
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
|
||||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-100 md:opacity-0 md:group-hover/item:opacity-100 transition-opacity">
|
<div
|
||||||
<DropdownMenu>
|
className={cn(
|
||||||
|
"absolute right-1 top-1/2 -translate-y-1/2 transition-opacity",
|
||||||
|
isMobile ? "opacity-0 pointer-events-none" : "opacity-0 group-hover/item:opacity-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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="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" />
|
||||||
|
|
@ -105,7 +125,6 @@ export function ChatListItem({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete();
|
onDelete();
|
||||||
}}
|
}}
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
<span>{t("delete")}</span>
|
<span>{t("delete")}</span>
|
||||||
|
|
|
||||||
211
surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
Normal file
211
surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
|
||||||
|
import {
|
||||||
|
DocumentsTableShell,
|
||||||
|
type SortKey,
|
||||||
|
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell";
|
||||||
|
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||||
|
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||||
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
|
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||||
|
import { useDocuments } from "@/hooks/use-documents";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||||
|
|
||||||
|
interface DocumentsSidebarProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) {
|
||||||
|
const t = useTranslations("documents");
|
||||||
|
const tSidebar = useTranslations("sidebar");
|
||||||
|
const params = useParams();
|
||||||
|
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||||
|
const searchSpaceId = Number(params.search_space_id);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const debouncedSearch = useDebouncedValue(search, 250);
|
||||||
|
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>("created_at");
|
||||||
|
const [sortDesc, setSortDesc] = useState(true);
|
||||||
|
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
||||||
|
|
||||||
|
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
|
||||||
|
const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]);
|
||||||
|
|
||||||
|
const handleToggleChatMention = useCallback(
|
||||||
|
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
||||||
|
if (isMentioned) {
|
||||||
|
setSidebarDocs((prev) => prev.filter((d) => d.id !== doc.id));
|
||||||
|
} else {
|
||||||
|
setSidebarDocs((prev) => {
|
||||||
|
if (prev.some((d) => d.id === doc.id)) return prev;
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setSidebarDocs]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSearchMode = !!debouncedSearch.trim();
|
||||||
|
|
||||||
|
const {
|
||||||
|
documents: realtimeDocuments,
|
||||||
|
typeCounts: realtimeTypeCounts,
|
||||||
|
loading: realtimeLoading,
|
||||||
|
loadingMore: realtimeLoadingMore,
|
||||||
|
hasMore: realtimeHasMore,
|
||||||
|
loadMore: realtimeLoadMore,
|
||||||
|
error: realtimeError,
|
||||||
|
} = useDocuments(searchSpaceId, activeTypes, sortKey, sortDesc ? "desc" : "asc");
|
||||||
|
|
||||||
|
const {
|
||||||
|
documents: searchDocuments,
|
||||||
|
loading: searchLoading,
|
||||||
|
loadingMore: searchLoadingMore,
|
||||||
|
hasMore: searchHasMore,
|
||||||
|
loadMore: searchLoadMore,
|
||||||
|
error: searchError,
|
||||||
|
removeItems: searchRemoveItems,
|
||||||
|
} = useDocumentSearch(searchSpaceId, debouncedSearch, activeTypes, isSearchMode && open);
|
||||||
|
|
||||||
|
const displayDocs = isSearchMode ? searchDocuments : realtimeDocuments;
|
||||||
|
const loading = isSearchMode ? searchLoading : realtimeLoading;
|
||||||
|
const error = isSearchMode ? searchError : !!realtimeError;
|
||||||
|
const hasMore = isSearchMode ? searchHasMore : realtimeHasMore;
|
||||||
|
const loadingMore = isSearchMode ? searchLoadingMore : realtimeLoadingMore;
|
||||||
|
const onLoadMore = isSearchMode ? searchLoadMore : realtimeLoadMore;
|
||||||
|
|
||||||
|
const onToggleType = (type: DocumentTypeEnum, checked: boolean) => {
|
||||||
|
setActiveTypes((prev) => {
|
||||||
|
if (checked) {
|
||||||
|
return prev.includes(type) ? prev : [...prev, type];
|
||||||
|
}
|
||||||
|
return prev.filter((t) => t !== type);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteDocument = useCallback(
|
||||||
|
async (id: number): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await deleteDocumentMutation({ id });
|
||||||
|
toast.success(t("delete_success") || "Document deleted");
|
||||||
|
setSidebarDocs((prev) => prev.filter((d) => d.id !== id));
|
||||||
|
if (isSearchMode) {
|
||||||
|
searchRemoveItems([id]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error deleting document:", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteDocumentMutation, isSearchMode, t, searchRemoveItems, setSidebarDocs]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortKeyRef = useRef(sortKey);
|
||||||
|
const sortDescRef = useRef(sortDesc);
|
||||||
|
sortKeyRef.current = sortKey;
|
||||||
|
sortDescRef.current = sortDesc;
|
||||||
|
|
||||||
|
const handleSortChange = useCallback((key: SortKey) => {
|
||||||
|
const currentKey = sortKeyRef.current;
|
||||||
|
const currentDesc = sortDescRef.current;
|
||||||
|
|
||||||
|
if (currentKey === key && currentDesc) {
|
||||||
|
setSortKey("created_at");
|
||||||
|
setSortDesc(true);
|
||||||
|
} else if (currentKey === key) {
|
||||||
|
setSortDesc(true);
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDesc(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && open) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
|
const documentsContent = (
|
||||||
|
<>
|
||||||
|
<div className="shrink-0 p-4 pb-10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isMobile && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="sr-only">{tSidebar("close") || "Close"}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<h2 className="text-lg font-semibold">{t("title") || "Documents"}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
<DocumentsFilters
|
||||||
|
typeCounts={realtimeTypeCounts}
|
||||||
|
onSearch={setSearch}
|
||||||
|
searchValue={search}
|
||||||
|
onToggleType={onToggleType}
|
||||||
|
activeTypes={activeTypes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocumentsTableShell
|
||||||
|
documents={displayDocs}
|
||||||
|
loading={!!loading}
|
||||||
|
error={!!error}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDesc={sortDesc}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
deleteDocument={handleDeleteDocument}
|
||||||
|
searchSpaceId={String(searchSpaceId)}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
onLoadMore={onLoadMore}
|
||||||
|
isSearchMode={isSearchMode}
|
||||||
|
mentionedDocIds={mentionedDocIds}
|
||||||
|
onToggleChatMention={handleToggleChatMention}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarSlideOutPanel
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
ariaLabel={t("title") || "Documents"}
|
||||||
|
width={isMobile ? undefined : 480}
|
||||||
|
>
|
||||||
|
{documentsContent}
|
||||||
|
</SidebarSlideOutPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||||
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
|
@ -49,6 +50,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import {
|
import {
|
||||||
isCommentReplyMetadata,
|
isCommentReplyMetadata,
|
||||||
isConnectorIndexingMetadata,
|
isConnectorIndexingMetadata,
|
||||||
|
isDocumentProcessingMetadata,
|
||||||
isNewMentionMetadata,
|
isNewMentionMetadata,
|
||||||
isPageLimitExceededMetadata,
|
isPageLimitExceededMetadata,
|
||||||
} from "@/contracts/types/inbox.types";
|
} from "@/contracts/types/inbox.types";
|
||||||
|
|
@ -60,9 +62,6 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||||
|
|
||||||
/**
|
|
||||||
* Get initials from name or email for avatar fallback
|
|
||||||
*/
|
|
||||||
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
|
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
|
||||||
if (name) {
|
if (name) {
|
||||||
return name
|
return name
|
||||||
|
|
@ -79,9 +78,6 @@ function getInitials(name: string | null | undefined, email: string | null | und
|
||||||
return "U";
|
return "U";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
|
|
||||||
*/
|
|
||||||
function formatInboxCount(count: number): string {
|
function formatInboxCount(count: number): string {
|
||||||
if (count <= 999) {
|
if (count <= 999) {
|
||||||
return count.toString();
|
return count.toString();
|
||||||
|
|
@ -90,9 +86,6 @@ function formatInboxCount(count: number): string {
|
||||||
return `${thousands}k+`;
|
return `${thousands}k+`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get display name for connector type
|
|
||||||
*/
|
|
||||||
function getConnectorTypeDisplayName(connectorType: string): string {
|
function getConnectorTypeDisplayName(connectorType: string): string {
|
||||||
const displayNames: Record<string, string> = {
|
const displayNames: Record<string, string> = {
|
||||||
GITHUB_CONNECTOR: "GitHub",
|
GITHUB_CONNECTOR: "GitHub",
|
||||||
|
|
@ -135,44 +128,36 @@ function getConnectorTypeDisplayName(connectorType: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
type InboxTab = "comments" | "status";
|
type InboxTab = "comments" | "status";
|
||||||
type InboxFilter = "all" | "unread";
|
type InboxFilter = "all" | "unread" | "errors";
|
||||||
|
|
||||||
// Tab-specific data source with independent pagination
|
|
||||||
interface TabDataSource {
|
interface TabDataSource {
|
||||||
items: InboxItem[];
|
items: InboxItem[];
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
loadingMore?: boolean;
|
loadingMore: boolean;
|
||||||
hasMore?: boolean;
|
hasMore: boolean;
|
||||||
loadMore?: () => void;
|
loadMore: () => void;
|
||||||
|
markAsRead: (id: number) => Promise<boolean>;
|
||||||
|
markAllAsRead: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InboxSidebarProps {
|
interface InboxSidebarProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
/** Mentions tab data source with independent pagination */
|
comments: TabDataSource;
|
||||||
mentions: TabDataSource;
|
|
||||||
/** Status tab data source with independent pagination */
|
|
||||||
status: TabDataSource;
|
status: TabDataSource;
|
||||||
/** Combined unread count for mark all as read */
|
|
||||||
totalUnreadCount: number;
|
totalUnreadCount: number;
|
||||||
markAsRead: (id: number) => Promise<boolean>;
|
|
||||||
markAllAsRead: () => Promise<boolean>;
|
|
||||||
onCloseMobileSidebar?: () => void;
|
onCloseMobileSidebar?: () => void;
|
||||||
/** Whether the inbox is docked (permanent) or floating */
|
|
||||||
isDocked?: boolean;
|
isDocked?: boolean;
|
||||||
/** Callback to toggle docked state */
|
|
||||||
onDockedChange?: (docked: boolean) => void;
|
onDockedChange?: (docked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InboxSidebar({
|
export function InboxSidebar({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
mentions,
|
comments,
|
||||||
status,
|
status,
|
||||||
totalUnreadCount,
|
totalUnreadCount,
|
||||||
markAsRead,
|
|
||||||
markAllAsRead,
|
|
||||||
onCloseMobileSidebar,
|
onCloseMobileSidebar,
|
||||||
isDocked = false,
|
isDocked = false,
|
||||||
onDockedChange,
|
onDockedChange,
|
||||||
|
|
@ -183,9 +168,7 @@ export function InboxSidebar({
|
||||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||||
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
||||||
|
|
||||||
// Comments collapsed state (desktop only, when docked)
|
|
||||||
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
|
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
|
||||||
// Target comment for navigation - also ensures comments panel is visible
|
|
||||||
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
@ -193,11 +176,9 @@ export function InboxSidebar({
|
||||||
const isSearchMode = !!debouncedSearch.trim();
|
const isSearchMode = !!debouncedSearch.trim();
|
||||||
const [activeTab, setActiveTab] = useState<InboxTab>("comments");
|
const [activeTab, setActiveTab] = useState<InboxTab>("comments");
|
||||||
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||||
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
// Dropdown state for filter menu (desktop only)
|
|
||||||
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
||||||
// Scroll shadow state for connector list
|
|
||||||
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
|
|
@ -205,15 +186,12 @@ export function InboxSidebar({
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||||
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||||
}, []);
|
}, []);
|
||||||
// Drawer state for filter menu (mobile only)
|
|
||||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Prefetch trigger ref - placed on item near the end
|
|
||||||
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Server-side search query (enabled only when user is typing a search)
|
// Server-side search query
|
||||||
// Determines which notification types to search based on active tab
|
|
||||||
const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined;
|
const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined;
|
||||||
const { data: searchResponse, isLoading: isSearchLoading } = useQuery({
|
const { data: searchResponse, isLoading: isSearchLoading } = useQuery({
|
||||||
queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab),
|
queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab),
|
||||||
|
|
@ -226,7 +204,7 @@ export function InboxSidebar({
|
||||||
limit: 50,
|
limit: 50,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
staleTime: 30 * 1000, // 30 seconds (search results don't need to be super fresh)
|
staleTime: 30 * 1000,
|
||||||
enabled: isSearchMode && open,
|
enabled: isSearchMode && open,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -244,129 +222,128 @@ export function InboxSidebar({
|
||||||
return () => document.removeEventListener("keydown", handleEscape);
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
}, [open, onOpenChange]);
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
// Only lock body scroll on mobile when inbox is open
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !isMobile) return;
|
if (!open || !isMobile) return;
|
||||||
|
|
||||||
// Store original overflow to restore on cleanup
|
|
||||||
const originalOverflow = document.body.style.overflow;
|
const originalOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = originalOverflow;
|
document.body.style.overflow = originalOverflow;
|
||||||
};
|
};
|
||||||
}, [open, isMobile]);
|
}, [open, isMobile]);
|
||||||
|
|
||||||
// Reset connector filter when switching away from status tab
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab !== "status") {
|
if (activeTab !== "status") {
|
||||||
setSelectedConnector(null);
|
setSelectedSource(null);
|
||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
// Each tab uses its own data source for independent pagination
|
// Active tab's data source — fully independent loading, pagination, and counts
|
||||||
// Comments tab: uses mentions data source (fetches only mention/reply types from server)
|
const activeSource = activeTab === "comments" ? comments : status;
|
||||||
const commentsItems = mentions.items;
|
|
||||||
|
|
||||||
// Status tab: filters status data source (fetches all types) to status-specific types
|
// Fetch source types for the status tab filter
|
||||||
const statusItems = useMemo(
|
const { data: sourceTypesData } = useQuery({
|
||||||
() =>
|
queryKey: cacheKeys.notifications.sourceTypes(searchSpaceId),
|
||||||
status.items.filter(
|
queryFn: () => notificationsApiService.getSourceTypes(searchSpaceId ?? undefined),
|
||||||
(item) =>
|
staleTime: 60 * 1000,
|
||||||
item.type === "connector_indexing" ||
|
enabled: open && activeTab === "status",
|
||||||
item.type === "document_processing" ||
|
});
|
||||||
item.type === "page_limit_exceeded" ||
|
|
||||||
item.type === "connector_deletion"
|
const statusSourceOptions = useMemo(() => {
|
||||||
),
|
if (!sourceTypesData?.sources) return [];
|
||||||
[status.items]
|
|
||||||
|
return sourceTypesData.sources.map((source) => ({
|
||||||
|
key: source.key,
|
||||||
|
type: source.type,
|
||||||
|
category: source.category,
|
||||||
|
displayName:
|
||||||
|
source.category === "connector"
|
||||||
|
? getConnectorTypeDisplayName(source.type)
|
||||||
|
: getDocumentTypeLabel(source.type),
|
||||||
|
}));
|
||||||
|
}, [sourceTypesData]);
|
||||||
|
|
||||||
|
// Client-side filter: source type
|
||||||
|
const matchesSourceFilter = useCallback(
|
||||||
|
(item: InboxItem): boolean => {
|
||||||
|
if (!selectedSource) return true;
|
||||||
|
if (selectedSource.startsWith("connector:")) {
|
||||||
|
const connectorType = selectedSource.slice("connector:".length);
|
||||||
|
return (
|
||||||
|
item.type === "connector_indexing" &&
|
||||||
|
isConnectorIndexingMetadata(item.metadata) &&
|
||||||
|
item.metadata.connector_type === connectorType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (selectedSource.startsWith("doctype:")) {
|
||||||
|
const docType = selectedSource.slice("doctype:".length);
|
||||||
|
return (
|
||||||
|
item.type === "document_processing" &&
|
||||||
|
isDocumentProcessingMetadata(item.metadata) &&
|
||||||
|
item.metadata.document_type === docType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[selectedSource]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pagination switches based on active tab
|
// Client-side filter: unread / errors
|
||||||
const loading = activeTab === "comments" ? mentions.loading : status.loading;
|
const matchesActiveFilter = useCallback(
|
||||||
const loadingMore =
|
(item: InboxItem): boolean => {
|
||||||
activeTab === "comments" ? (mentions.loadingMore ?? false) : (status.loadingMore ?? false);
|
if (activeFilter === "unread") return !item.read;
|
||||||
const hasMore =
|
if (activeFilter === "errors") {
|
||||||
activeTab === "comments" ? (mentions.hasMore ?? false) : (status.hasMore ?? false);
|
if (item.type === "page_limit_exceeded") return true;
|
||||||
const loadMore = activeTab === "comments" ? mentions.loadMore : status.loadMore;
|
const meta = item.metadata as Record<string, unknown> | undefined;
|
||||||
|
return typeof meta?.status === "string" && meta.status === "failed";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[activeFilter]
|
||||||
|
);
|
||||||
|
|
||||||
// Get unique connector types from status items for filtering
|
// Two data paths: search mode (API) or default (per-tab data source)
|
||||||
const uniqueConnectorTypes = useMemo(() => {
|
|
||||||
const connectorTypes = new Set<string>();
|
|
||||||
|
|
||||||
statusItems
|
|
||||||
.filter((item) => item.type === "connector_indexing")
|
|
||||||
.forEach((item) => {
|
|
||||||
// Use type guard for safe metadata access
|
|
||||||
if (isConnectorIndexingMetadata(item.metadata)) {
|
|
||||||
connectorTypes.add(item.metadata.connector_type);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(connectorTypes).map((type) => ({
|
|
||||||
type,
|
|
||||||
displayName: getConnectorTypeDisplayName(type),
|
|
||||||
}));
|
|
||||||
}, [statusItems]);
|
|
||||||
|
|
||||||
// Get items for current tab
|
|
||||||
const displayItems = activeTab === "comments" ? commentsItems : statusItems;
|
|
||||||
|
|
||||||
// Filter items based on filter type, connector filter, and search mode
|
|
||||||
// When searching: use server-side API results (searches ALL notifications)
|
|
||||||
// When not searching: use Electric real-time items (fast, local)
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
// In search mode, use API results
|
let tabItems: InboxItem[];
|
||||||
let items: InboxItem[] = isSearchMode ? (searchResponse?.items ?? []) : displayItems;
|
|
||||||
|
|
||||||
// For status tab search results, filter to status-specific types
|
if (isSearchMode) {
|
||||||
if (isSearchMode && activeTab === "status") {
|
tabItems = searchResponse?.items ?? [];
|
||||||
items = items.filter(
|
} else {
|
||||||
(item) =>
|
tabItems = activeSource.items;
|
||||||
item.type === "connector_indexing" ||
|
|
||||||
item.type === "document_processing" ||
|
|
||||||
item.type === "page_limit_exceeded" ||
|
|
||||||
item.type === "connector_deletion"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply read/unread filter
|
let result = tabItems;
|
||||||
if (activeFilter === "unread") {
|
if (activeFilter !== "all") {
|
||||||
items = items.filter((item) => !item.read);
|
result = result.filter(matchesActiveFilter);
|
||||||
|
}
|
||||||
|
if (activeTab === "status" && selectedSource) {
|
||||||
|
result = result.filter(matchesSourceFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply connector filter (only for status tab)
|
return result;
|
||||||
if (activeTab === "status" && selectedConnector) {
|
}, [
|
||||||
items = items.filter((item) => {
|
isSearchMode,
|
||||||
if (item.type === "connector_indexing") {
|
searchResponse,
|
||||||
// Use type guard for safe metadata access
|
activeSource.items,
|
||||||
if (isConnectorIndexingMetadata(item.metadata)) {
|
activeTab,
|
||||||
return item.metadata.connector_type === selectedConnector;
|
activeFilter,
|
||||||
}
|
selectedSource,
|
||||||
return false;
|
matchesActiveFilter,
|
||||||
}
|
matchesSourceFilter,
|
||||||
return false; // Hide document_processing when a specific connector is selected
|
]);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
// Infinite scroll — uses active tab's pagination
|
||||||
}, [displayItems, searchResponse, isSearchMode, activeFilter, activeTab, selectedConnector]);
|
|
||||||
|
|
||||||
// Intersection Observer for infinite scroll with prefetching
|
|
||||||
// Re-runs when active tab changes so each tab gets its own pagination
|
|
||||||
// Disabled during server-side search (search results are not paginated via infinite scroll)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return;
|
if (!activeSource.hasMore || activeSource.loadingMore || !open || isSearchMode) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
// When trigger element is visible, load more
|
|
||||||
if (entries[0]?.isIntersecting) {
|
if (entries[0]?.isIntersecting) {
|
||||||
loadMore();
|
activeSource.loadMore();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: null, // viewport
|
root: null,
|
||||||
rootMargin: "100px", // Start loading 100px before visible
|
rootMargin: "100px",
|
||||||
threshold: 0,
|
threshold: 0,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -376,17 +353,13 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]);
|
}, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, open, isSearchMode]);
|
||||||
|
|
||||||
// Unread counts from server-side accurate totals (passed via props)
|
|
||||||
const unreadCommentsCount = mentions.unreadCount;
|
|
||||||
const unreadStatusCount = status.unreadCount;
|
|
||||||
|
|
||||||
const handleItemClick = useCallback(
|
const handleItemClick = useCallback(
|
||||||
async (item: InboxItem) => {
|
async (item: InboxItem) => {
|
||||||
if (!item.read) {
|
if (!item.read) {
|
||||||
setMarkingAsReadId(item.id);
|
setMarkingAsReadId(item.id);
|
||||||
await markAsRead(item.id);
|
await activeSource.markAsRead(item.id);
|
||||||
setMarkingAsReadId(null);
|
setMarkingAsReadId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -427,7 +400,6 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (item.type === "page_limit_exceeded") {
|
} else if (item.type === "page_limit_exceeded") {
|
||||||
// Navigate to the upgrade/more-pages page
|
|
||||||
if (isPageLimitExceededMetadata(item.metadata)) {
|
if (isPageLimitExceededMetadata(item.metadata)) {
|
||||||
const actionUrl = item.metadata.action_url;
|
const actionUrl = item.metadata.action_url;
|
||||||
if (actionUrl) {
|
if (actionUrl) {
|
||||||
|
|
@ -438,12 +410,12 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
|
[activeSource.markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMarkAllAsRead = useCallback(async () => {
|
const handleMarkAllAsRead = useCallback(async () => {
|
||||||
await markAllAsRead();
|
await Promise.all([comments.markAllAsRead(), status.markAllAsRead()]);
|
||||||
}, [markAllAsRead]);
|
}, [comments.markAllAsRead, status.markAllAsRead]);
|
||||||
|
|
||||||
const handleClearSearch = useCallback(() => {
|
const handleClearSearch = useCallback(() => {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
|
|
@ -469,7 +441,6 @@ export function InboxSidebar({
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusIcon = (item: InboxItem) => {
|
const getStatusIcon = (item: InboxItem) => {
|
||||||
// For mentions and comment replies, show the author's avatar
|
|
||||||
if (item.type === "new_mention" || item.type === "comment_reply") {
|
if (item.type === "new_mention" || item.type === "comment_reply") {
|
||||||
const metadata =
|
const metadata =
|
||||||
item.type === "new_mention"
|
item.type === "new_mention"
|
||||||
|
|
@ -501,7 +472,6 @@ export function InboxSidebar({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For page limit exceeded, show a warning icon with amber/orange color
|
|
||||||
if (item.type === "page_limit_exceeded") {
|
if (item.type === "page_limit_exceeded") {
|
||||||
return (
|
return (
|
||||||
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10">
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10">
|
||||||
|
|
@ -510,8 +480,6 @@ export function InboxSidebar({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For status items (connector/document), show status icons
|
|
||||||
// Safely access status from metadata
|
|
||||||
const metadata = item.metadata as Record<string, unknown>;
|
const metadata = item.metadata as Record<string, unknown>;
|
||||||
const status = typeof metadata?.status === "string" ? metadata.status : undefined;
|
const status = typeof metadata?.status === "string" ? metadata.status : undefined;
|
||||||
|
|
||||||
|
|
@ -558,13 +526,13 @@ export function InboxSidebar({
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
// Shared content component for both docked and floating modes
|
const isLoading = isSearchMode ? isSearchLoading : activeSource.loading;
|
||||||
|
|
||||||
const inboxContent = (
|
const inboxContent = (
|
||||||
<>
|
<>
|
||||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Back button - mobile only */}
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -579,7 +547,6 @@ export function InboxSidebar({
|
||||||
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Mobile: Button that opens bottom drawer */}
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -605,7 +572,6 @@ export function InboxSidebar({
|
||||||
</DrawerTitle>
|
</DrawerTitle>
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
{/* Filter section */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||||
{t("filter") || "Filter"}
|
{t("filter") || "Filter"}
|
||||||
|
|
@ -649,56 +615,74 @@ export function InboxSidebar({
|
||||||
</span>
|
</span>
|
||||||
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
|
{activeTab === "status" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveFilter("errors");
|
||||||
|
setFilterDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||||
|
activeFilter === "errors"
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span>{t("errors_only") || "Errors only"}</span>
|
||||||
|
</span>
|
||||||
|
{activeFilter === "errors" && <Check className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Connectors section - only for status tab */}
|
{activeTab === "status" && statusSourceOptions.length > 0 && (
|
||||||
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||||
{t("connectors") || "Connectors"}
|
{t("sources") || "Sources"}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedConnector(null);
|
setSelectedSource(null);
|
||||||
setFilterDrawerOpen(false);
|
setFilterDrawerOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||||
selectedConnector === null
|
selectedSource === null
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "hover:bg-muted"
|
: "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
<span>{t("all_connectors") || "All connectors"}</span>
|
<span>{t("all_sources") || "All sources"}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
{selectedSource === null && <Check className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
{uniqueConnectorTypes.map((connector) => (
|
{statusSourceOptions.map((source) => (
|
||||||
<button
|
<button
|
||||||
key={connector.type}
|
key={source.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedConnector(connector.type);
|
setSelectedSource(source.key);
|
||||||
setFilterDrawerOpen(false);
|
setFilterDrawerOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||||
selectedConnector === connector.type
|
selectedSource === source.key
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "hover:bg-muted"
|
: "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
{getConnectorIcon(source.type, "h-4 w-4")}
|
||||||
<span>{connector.displayName}</span>
|
<span>{source.displayName}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedConnector === connector.type && (
|
{selectedSource === source.key && <Check className="h-4 w-4" />}
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -709,7 +693,6 @@ export function InboxSidebar({
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
/* Desktop: Dropdown menu */
|
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={openDropdown === "filter"}
|
open={openDropdown === "filter"}
|
||||||
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
|
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
|
||||||
|
|
@ -727,7 +710,10 @@ export function InboxSidebar({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="end"
|
align="end"
|
||||||
className={cn("z-80 select-none", activeTab === "status" ? "w-52" : "w-44")}
|
className={cn(
|
||||||
|
"z-80 select-none max-h-[60vh] overflow-hidden flex flex-col",
|
||||||
|
activeTab === "status" ? "w-52" : "w-44"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
|
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
|
||||||
{t("filter") || "Filter"}
|
{t("filter") || "Filter"}
|
||||||
|
|
@ -752,13 +738,25 @@ export function InboxSidebar({
|
||||||
</span>
|
</span>
|
||||||
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
{activeTab === "status" && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setActiveFilter("errors")}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span>{t("errors_only") || "Errors only"}</span>
|
||||||
|
</span>
|
||||||
|
{activeFilter === "errors" && <Check className="h-4 w-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{activeTab === "status" && statusSourceOptions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
|
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
|
||||||
{t("connectors") || "Connectors"}
|
{t("sources") || "Sources"}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<div
|
<div
|
||||||
className="relative max-h-[30vh] overflow-y-auto -mb-1"
|
className="relative max-h-[30vh] overflow-y-auto overflow-x-hidden -mb-1"
|
||||||
onScroll={handleConnectorScroll}
|
onScroll={handleConnectorScroll}
|
||||||
style={{
|
style={{
|
||||||
maskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
|
maskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
|
@ -766,26 +764,26 @@ export function InboxSidebar({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setSelectedConnector(null)}
|
onClick={() => setSelectedSource(null)}
|
||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
<span>{t("all_connectors") || "All connectors"}</span>
|
<span>{t("all_sources") || "All sources"}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
{selectedSource === null && <Check className="h-4 w-4" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{uniqueConnectorTypes.map((connector) => (
|
{statusSourceOptions.map((source) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={connector.type}
|
key={source.key}
|
||||||
onClick={() => setSelectedConnector(connector.type)}
|
onClick={() => setSelectedSource(source.key)}
|
||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
{getConnectorIcon(source.type, "h-4 w-4")}
|
||||||
<span>{connector.displayName}</span>
|
<span>{source.displayName}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
|
{selectedSource === source.key && <Check className="h-4 w-4" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -824,7 +822,6 @@ export function InboxSidebar({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Dock/Undock button - desktop only */}
|
|
||||||
{!isMobile && onDockedChange && (
|
{!isMobile && onDockedChange && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -834,12 +831,10 @@ export function InboxSidebar({
|
||||||
className="h-8 w-8 rounded-full"
|
className="h-8 w-8 rounded-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isDocked) {
|
if (isDocked) {
|
||||||
// Collapse: show comments immediately, then close inbox
|
|
||||||
setCommentsCollapsed(false);
|
setCommentsCollapsed(false);
|
||||||
onDockedChange(false);
|
onDockedChange(false);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} else {
|
} else {
|
||||||
// Expand: hide comments immediately
|
|
||||||
setCommentsCollapsed(true);
|
setCommentsCollapsed(true);
|
||||||
onDockedChange(true);
|
onDockedChange(true);
|
||||||
}
|
}
|
||||||
|
|
@ -886,7 +881,13 @@ export function InboxSidebar({
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(value) => setActiveTab(value as InboxTab)}
|
onValueChange={(value) => {
|
||||||
|
const tab = value as InboxTab;
|
||||||
|
setActiveTab(tab);
|
||||||
|
if (tab !== "status" && activeFilter === "errors") {
|
||||||
|
setActiveFilter("all");
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="shrink-0 mx-4"
|
className="shrink-0 mx-4"
|
||||||
>
|
>
|
||||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||||
|
|
@ -898,7 +899,7 @@ export function InboxSidebar({
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
<span>{t("comments") || "Comments"}</span>
|
<span>{t("comments") || "Comments"}</span>
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
{formatInboxCount(unreadCommentsCount)}
|
{formatInboxCount(comments.unreadCount)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -910,7 +911,7 @@ export function InboxSidebar({
|
||||||
<History className="h-4 w-4" />
|
<History className="h-4 w-4" />
|
||||||
<span>{t("status") || "Status"}</span>
|
<span>{t("status") || "Status"}</span>
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
{formatInboxCount(unreadStatusCount)}
|
{formatInboxCount(status.unreadCount)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -918,11 +919,10 @@ export function InboxSidebar({
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
{(isSearchMode ? isSearchLoading : loading) ? (
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{activeTab === "comments"
|
{activeTab === "comments"
|
||||||
? /* Comments skeleton: avatar + two-line text + time */
|
? [85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
|
||||||
[85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
|
|
||||||
<div
|
<div
|
||||||
key={`skeleton-comment-${i}`}
|
key={`skeleton-comment-${i}`}
|
||||||
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
||||||
|
|
@ -935,8 +935,7 @@ export function InboxSidebar({
|
||||||
<Skeleton className="h-3 w-6 shrink-0 rounded" />
|
<Skeleton className="h-3 w-6 shrink-0 rounded" />
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
: /* Status skeleton: status icon circle + two-line text + time */
|
: [75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
||||||
[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
|
||||||
<div
|
<div
|
||||||
key={`skeleton-status-${i}`}
|
key={`skeleton-status-${i}`}
|
||||||
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
||||||
|
|
@ -957,9 +956,8 @@ export function InboxSidebar({
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filteredItems.map((item, index) => {
|
{filteredItems.map((item, index) => {
|
||||||
const isMarkingAsRead = markingAsReadId === item.id;
|
const isMarkingAsRead = markingAsReadId === item.id;
|
||||||
// Place prefetch trigger on 5th item from end (only when not searching)
|
|
||||||
const isPrefetchTrigger =
|
const isPrefetchTrigger =
|
||||||
!isSearchMode && hasMore && index === filteredItems.length - 5;
|
!isSearchMode && activeSource.hasMore && index === filteredItems.length - 5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1028,7 +1026,6 @@ export function InboxSidebar({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Time and unread dot - fixed width to prevent content shift */}
|
|
||||||
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
|
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{formatTime(item.created_at)}
|
{formatTime(item.created_at)}
|
||||||
|
|
@ -1038,12 +1035,10 @@ export function InboxSidebar({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Fallback trigger at the very end if less than 5 items and not searching */}
|
{!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && (
|
||||||
{!isSearchMode && filteredItems.length < 5 && hasMore && (
|
|
||||||
<div ref={prefetchTriggerRef} className="h-1" />
|
<div ref={prefetchTriggerRef} className="h-1" />
|
||||||
)}
|
)}
|
||||||
{/* Loading more skeletons at the bottom during infinite scroll */}
|
{activeSource.loadingMore &&
|
||||||
{loadingMore &&
|
|
||||||
(activeTab === "comments"
|
(activeTab === "comments"
|
||||||
? [80, 60, 90].map((titleWidth, i) => (
|
? [80, 60, 90].map((titleWidth, i) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1100,11 +1095,10 @@ export function InboxSidebar({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// DOCKED MODE: Render as a static flex child (no animation, no click-away)
|
|
||||||
if (isDocked && open && !isMobile) {
|
if (isDocked && open && !isMobile) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="h-full w-[360px] shrink-0 bg-background flex flex-col border-r"
|
className="h-full w-[360px] shrink-0 bg-sidebar text-sidebar-foreground flex flex-col border-r"
|
||||||
aria-label={t("inbox") || "Inbox"}
|
aria-label={t("inbox") || "Inbox"}
|
||||||
>
|
>
|
||||||
{inboxContent}
|
{inboxContent}
|
||||||
|
|
@ -1112,7 +1106,6 @@ export function InboxSidebar({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FLOATING MODE: Render with animation and click-away layer
|
|
||||||
return (
|
return (
|
||||||
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
|
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
|
||||||
{inboxContent}
|
{inboxContent}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { NavItem } from "../../types/layout.types";
|
import type { NavItem } from "../../types/layout.types";
|
||||||
|
|
@ -39,11 +40,15 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
{...joyrideAttr}
|
{...joyrideAttr}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{item.badge && (
|
{item.showSpinner ? (
|
||||||
|
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-primary/15">
|
||||||
|
<Spinner size="xs" className="text-primary" />
|
||||||
|
</span>
|
||||||
|
) : item.badge ? (
|
||||||
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full bg-red-500 text-white text-[9px] font-medium">
|
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full bg-red-500 text-white text-[9px] font-medium">
|
||||||
{item.badge}
|
{item.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
<span className="sr-only">{item.title}</span>
|
<span className="sr-only">{item.title}</span>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -67,7 +72,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
)}
|
)}
|
||||||
{...joyrideAttr}
|
{...joyrideAttr}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
{item.showSpinner ? (
|
||||||
|
<Spinner size="sm" className="shrink-0 text-primary" />
|
||||||
|
) : (
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
)}
|
||||||
<span className="flex-1 truncate">{item.title}</span>
|
<span className="flex-1 truncate">{item.title}</span>
|
||||||
{item.badge && (
|
{item.badge && (
|
||||||
<span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
<span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { PanelLeft, PanelLeftClose } from "lucide-react";
|
import { PanelLeft, PanelLeftClose } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
|
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
|
||||||
|
|
||||||
|
|
@ -18,7 +19,7 @@ export function SidebarCollapseButton({
|
||||||
disableTooltip = false,
|
disableTooltip = false,
|
||||||
}: SidebarCollapseButtonProps) {
|
}: SidebarCollapseButtonProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
const { shortcut } = usePlatformShortcut();
|
const { shortcutKeys } = usePlatformShortcut();
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
|
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
|
||||||
|
|
@ -35,9 +36,10 @@ export function SidebarCollapseButton({
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
|
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
|
||||||
{isCollapsed
|
<span className="flex items-center">
|
||||||
? `${t("expand_sidebar")} ${shortcut("Mod", "\\")}`
|
{isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
|
||||||
: `${t("collapse_sidebar")} ${shortcut("Mod", "\\")}`}
|
<ShortcutKbd keys={shortcutKeys("Mod", "\\")} />
|
||||||
|
</span>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -56,7 +55,6 @@ export function SidebarHeader({
|
||||||
<UserPen className="h-4 w-4" />
|
<UserPen className="h-4 w-4" />
|
||||||
{t("manage_members")}
|
{t("manage_members")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={onSettings}>
|
<DropdownMenuItem onClick={onSettings}>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
{t("search_space_settings")}
|
{t("search_space_settings")}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export function SidebarSlideOutPanel({
|
||||||
exit={{ x: "-100%" }}
|
exit={{ x: "-100%" }}
|
||||||
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full w-full bg-background flex flex-col pointer-events-auto select-none",
|
"h-full w-full bg-sidebar text-sidebar-foreground flex flex-col pointer-events-auto select-none",
|
||||||
"sm:border-r sm:shadow-xl"
|
"sm:border-r sm:shadow-xl"
|
||||||
)}
|
)}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ export function SidebarUserProfile({
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onUserSettings}>
|
<DropdownMenuItem onClick={onUserSettings}>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
|
|
@ -256,7 +256,7 @@ export function SidebarUserProfile({
|
||||||
</DropdownMenuPortal>
|
</DropdownMenuPortal>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||||
{isLoggingOut ? (
|
{isLoggingOut ? (
|
||||||
|
|
@ -310,7 +310,7 @@ export function SidebarUserProfile({
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onUserSettings}>
|
<DropdownMenuItem onClick={onUserSettings}>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
|
|
@ -378,7 +378,7 @@ export function SidebarUserProfile({
|
||||||
</DropdownMenuPortal>
|
</DropdownMenuPortal>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||||
{isLoggingOut ? <Spinner size="sm" className="mr-2" /> : <LogOut className="h-4 w-4" />}
|
{isLoggingOut ? <Spinner size="sm" className="mr-2" /> : <LogOut className="h-4 w-4" />}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
|
||||||
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
||||||
export { AnnouncementsSidebar } from "./AnnouncementsSidebar";
|
export { AnnouncementsSidebar } from "./AnnouncementsSidebar";
|
||||||
export { ChatListItem } from "./ChatListItem";
|
export { ChatListItem } from "./ChatListItem";
|
||||||
|
export { DocumentsSidebar } from "./DocumentsSidebar";
|
||||||
export { InboxSidebar } from "./InboxSidebar";
|
export { InboxSidebar } from "./InboxSidebar";
|
||||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||||
export { NavSection } from "./NavSection";
|
export { NavSection } from "./NavSection";
|
||||||
|
|
|
||||||
|
|
@ -7,38 +7,39 @@ import type {
|
||||||
ImageGenerationConfig,
|
ImageGenerationConfig,
|
||||||
NewLLMConfigPublic,
|
NewLLMConfigPublic,
|
||||||
} from "@/contracts/types/new-llm-config.types";
|
} from "@/contracts/types/new-llm-config.types";
|
||||||
import { ImageConfigSidebar } from "./image-config-sidebar";
|
import { ImageConfigDialog } from "./image-config-dialog";
|
||||||
import { ModelConfigSidebar } from "./model-config-sidebar";
|
import { ModelConfigDialog } from "./model-config-dialog";
|
||||||
import { ModelSelector } from "./model-selector";
|
import { ModelSelector } from "./model-selector";
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
|
||||||
// LLM config sidebar state
|
// LLM config dialog state
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [selectedConfig, setSelectedConfig] = useState<
|
const [selectedConfig, setSelectedConfig] = useState<
|
||||||
NewLLMConfigPublic | GlobalNewLLMConfig | null
|
NewLLMConfigPublic | GlobalNewLLMConfig | null
|
||||||
>(null);
|
>(null);
|
||||||
const [isGlobal, setIsGlobal] = useState(false);
|
const [isGlobal, setIsGlobal] = useState(false);
|
||||||
const [sidebarMode, setSidebarMode] = useState<"create" | "edit" | "view">("view");
|
const [dialogMode, setDialogMode] = useState<"create" | "edit" | "view">("view");
|
||||||
|
|
||||||
// Image config sidebar state
|
// Image config dialog state
|
||||||
const [imageSidebarOpen, setImageSidebarOpen] = useState(false);
|
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
||||||
const [selectedImageConfig, setSelectedImageConfig] = useState<
|
const [selectedImageConfig, setSelectedImageConfig] = useState<
|
||||||
ImageGenerationConfig | GlobalImageGenConfig | null
|
ImageGenerationConfig | GlobalImageGenConfig | null
|
||||||
>(null);
|
>(null);
|
||||||
const [isImageGlobal, setIsImageGlobal] = useState(false);
|
const [isImageGlobal, setIsImageGlobal] = useState(false);
|
||||||
const [imageSidebarMode, setImageSidebarMode] = useState<"create" | "edit" | "view">("view");
|
const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view");
|
||||||
|
|
||||||
// LLM handlers
|
// LLM handlers
|
||||||
const handleEditLLMConfig = useCallback(
|
const handleEditLLMConfig = useCallback(
|
||||||
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
|
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
|
||||||
setSelectedConfig(config);
|
setSelectedConfig(config);
|
||||||
setIsGlobal(global);
|
setIsGlobal(global);
|
||||||
setSidebarMode(global ? "view" : "edit");
|
setDialogMode(global ? "view" : "edit");
|
||||||
setSidebarOpen(true);
|
setDialogOpen(true);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
@ -46,12 +47,12 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
||||||
const handleAddNewLLM = useCallback(() => {
|
const handleAddNewLLM = useCallback(() => {
|
||||||
setSelectedConfig(null);
|
setSelectedConfig(null);
|
||||||
setIsGlobal(false);
|
setIsGlobal(false);
|
||||||
setSidebarMode("create");
|
setDialogMode("create");
|
||||||
setSidebarOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSidebarClose = useCallback((open: boolean) => {
|
const handleDialogClose = useCallback((open: boolean) => {
|
||||||
setSidebarOpen(open);
|
setDialogOpen(open);
|
||||||
if (!open) setSelectedConfig(null);
|
if (!open) setSelectedConfig(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -59,22 +60,22 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
||||||
const handleAddImageModel = useCallback(() => {
|
const handleAddImageModel = useCallback(() => {
|
||||||
setSelectedImageConfig(null);
|
setSelectedImageConfig(null);
|
||||||
setIsImageGlobal(false);
|
setIsImageGlobal(false);
|
||||||
setImageSidebarMode("create");
|
setImageDialogMode("create");
|
||||||
setImageSidebarOpen(true);
|
setImageDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEditImageConfig = useCallback(
|
const handleEditImageConfig = useCallback(
|
||||||
(config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => {
|
(config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => {
|
||||||
setSelectedImageConfig(config);
|
setSelectedImageConfig(config);
|
||||||
setIsImageGlobal(global);
|
setIsImageGlobal(global);
|
||||||
setImageSidebarMode(global ? "view" : "edit");
|
setImageDialogMode(global ? "view" : "edit");
|
||||||
setImageSidebarOpen(true);
|
setImageDialogOpen(true);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleImageSidebarClose = useCallback((open: boolean) => {
|
const handleImageDialogClose = useCallback((open: boolean) => {
|
||||||
setImageSidebarOpen(open);
|
setImageDialogOpen(open);
|
||||||
if (!open) setSelectedImageConfig(null);
|
if (!open) setSelectedImageConfig(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -85,22 +86,23 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
||||||
onAddNewLLM={handleAddNewLLM}
|
onAddNewLLM={handleAddNewLLM}
|
||||||
onEditImage={handleEditImageConfig}
|
onEditImage={handleEditImageConfig}
|
||||||
onAddNewImage={handleAddImageModel}
|
onAddNewImage={handleAddImageModel}
|
||||||
|
className={className}
|
||||||
/>
|
/>
|
||||||
<ModelConfigSidebar
|
<ModelConfigDialog
|
||||||
open={sidebarOpen}
|
open={dialogOpen}
|
||||||
onOpenChange={handleSidebarClose}
|
onOpenChange={handleDialogClose}
|
||||||
config={selectedConfig}
|
config={selectedConfig}
|
||||||
isGlobal={isGlobal}
|
isGlobal={isGlobal}
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
mode={sidebarMode}
|
mode={dialogMode}
|
||||||
/>
|
/>
|
||||||
<ImageConfigSidebar
|
<ImageConfigDialog
|
||||||
open={imageSidebarOpen}
|
open={imageDialogOpen}
|
||||||
onOpenChange={handleImageSidebarClose}
|
onOpenChange={handleImageDialogClose}
|
||||||
config={selectedImageConfig}
|
config={selectedImageConfig}
|
||||||
isGlobal={isImageGlobal}
|
isGlobal={isImageGlobal}
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
mode={imageSidebarMode}
|
mode={imageDialogMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -72,12 +72,15 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
// Query to check if thread has public snapshots
|
// Query to check if thread has public snapshots
|
||||||
const { data: snapshotsData } = useQuery({
|
const { data: snapshotsData } = useQuery({
|
||||||
queryKey: ["thread-snapshots", thread?.id],
|
queryKey: ["thread-snapshots", thread?.id],
|
||||||
queryFn: () => chatThreadsApiService.listPublicChatSnapshots({ thread_id: thread!.id }),
|
queryFn: () => {
|
||||||
|
const id = thread?.id;
|
||||||
|
if (id == null) throw new Error("Missing thread id");
|
||||||
|
return chatThreadsApiService.listPublicChatSnapshots({ thread_id: id });
|
||||||
|
},
|
||||||
enabled: !!thread?.id,
|
enabled: !!thread?.id,
|
||||||
staleTime: 30000, // Cache for 30 seconds
|
staleTime: 30000, // Cache for 30 seconds
|
||||||
});
|
});
|
||||||
const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0;
|
const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0;
|
||||||
const snapshotCount = snapshotsData?.snapshots?.length ?? 0;
|
|
||||||
|
|
||||||
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
||||||
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
||||||
|
|
@ -152,11 +155,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
<Earth className="h-4 w-4 text-muted-foreground" />
|
<Earth className="h-4 w-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>Manage public links</TooltipContent>
|
||||||
{snapshotCount === 1
|
|
||||||
? "This chat has a public link"
|
|
||||||
: `This chat has ${snapshotCount} public links`}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -167,7 +166,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0"
|
className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0 select-none"
|
||||||
>
|
>
|
||||||
<CurrentIcon className="h-4 w-4" />
|
<CurrentIcon className="h-4 w-4" />
|
||||||
<span className="hidden md:inline text-sm">{buttonLabel}</span>
|
<span className="hidden md:inline text-sm">{buttonLabel}</span>
|
||||||
|
|
@ -178,12 +177,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
|
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<div className="p-1.5 space-y-1 select-none">
|
<div className="p-1.5 space-y-1">
|
||||||
{/* Visibility Options */}
|
{/* Visibility Options */}
|
||||||
{visibilityOptions.map((option) => {
|
{visibilityOptions.map((option) => {
|
||||||
const isSelected = currentVisibility === option.value;
|
const isSelected = currentVisibility === option.value;
|
||||||
|
|
@ -196,27 +195,32 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
onClick={() => handleVisibilityChange(option.value)}
|
onClick={() => handleVisibilityChange(option.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||||
"hover:bg-accent/50 cursor-pointer",
|
"hover:bg-accent/50 dark:hover:bg-white/10 cursor-pointer",
|
||||||
"focus:outline-none",
|
"focus:outline-none",
|
||||||
isSelected && "bg-accent/80"
|
isSelected && "bg-accent/80 dark:bg-white/10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-7 rounded-md shrink-0 grid place-items-center",
|
"size-7 rounded-md shrink-0 grid place-items-center",
|
||||||
isSelected ? "bg-primary/10" : "bg-muted"
|
isSelected ? "bg-primary/10 dark:bg-white/10" : "bg-muted dark:bg-white/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4 block",
|
"size-4 block",
|
||||||
isSelected ? "text-primary" : "text-muted-foreground"
|
isSelected ? "text-primary dark:text-white" : "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-left min-w-0">
|
<div className="flex-1 text-left min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className={cn("text-sm font-medium", isSelected && "text-primary")}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
isSelected && "text-primary dark:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -231,7 +235,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
{canCreatePublicLink && (
|
{canCreatePublicLink && (
|
||||||
<>
|
<>
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="border-t border-border my-1" />
|
<div className="border-t border-border dark:border-white/5 my-1" />
|
||||||
|
|
||||||
{/* Public Link Option */}
|
{/* Public Link Option */}
|
||||||
<button
|
<button
|
||||||
|
|
@ -240,12 +244,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
disabled={isCreatingSnapshot}
|
disabled={isCreatingSnapshot}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||||
"hover:bg-accent/50 cursor-pointer",
|
"hover:bg-accent/50 dark:hover:bg-white/10 cursor-pointer",
|
||||||
"focus:outline-none",
|
"focus:outline-none",
|
||||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
|
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted dark:bg-white/5">
|
||||||
<Earth className="size-4 block text-muted-foreground" />
|
<Earth className="size-4 block text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-left min-w-0">
|
<div className="flex-1 text-left min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import { AlertCircle, Check, ChevronsUpDown, X } from "lucide-react";
|
||||||
AlertCircle,
|
|
||||||
Check,
|
|
||||||
ChevronsUpDown,
|
|
||||||
Globe,
|
|
||||||
ImageIcon,
|
|
||||||
Key,
|
|
||||||
Shuffle,
|
|
||||||
X,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -48,10 +38,11 @@ import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-g
|
||||||
import type {
|
import type {
|
||||||
GlobalImageGenConfig,
|
GlobalImageGenConfig,
|
||||||
ImageGenerationConfig,
|
ImageGenerationConfig,
|
||||||
|
ImageGenProvider,
|
||||||
} from "@/contracts/types/new-llm-config.types";
|
} from "@/contracts/types/new-llm-config.types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ImageConfigSidebarProps {
|
interface ImageConfigDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
config: ImageGenerationConfig | GlobalImageGenConfig | null;
|
config: ImageGenerationConfig | GlobalImageGenConfig | null;
|
||||||
|
|
@ -70,24 +61,25 @@ const INITIAL_FORM = {
|
||||||
api_version: "",
|
api_version: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ImageConfigSidebar({
|
export function ImageConfigDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
config,
|
config,
|
||||||
isGlobal,
|
isGlobal,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
mode,
|
mode,
|
||||||
}: ImageConfigSidebarProps) {
|
}: ImageConfigDialogProps) {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [formData, setFormData] = useState(INITIAL_FORM);
|
const [formData, setFormData] = useState(INITIAL_FORM);
|
||||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||||
|
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Reset form when opening
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (mode === "edit" && config && !isGlobal) {
|
if (mode === "edit" && config && !isGlobal) {
|
||||||
|
|
@ -103,15 +95,14 @@ export function ImageConfigSidebar({
|
||||||
} else if (mode === "create") {
|
} else if (mode === "create") {
|
||||||
setFormData(INITIAL_FORM);
|
setFormData(INITIAL_FORM);
|
||||||
}
|
}
|
||||||
|
setScrollPos("top");
|
||||||
}
|
}
|
||||||
}, [open, mode, config, isGlobal]);
|
}, [open, mode, config, isGlobal]);
|
||||||
|
|
||||||
// Mutations
|
|
||||||
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
|
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
|
||||||
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
|
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
|
||||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||||
|
|
||||||
// Escape key
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && open) onOpenChange(false);
|
if (e.key === "Escape" && open) onOpenChange(false);
|
||||||
|
|
@ -120,6 +111,13 @@ export function ImageConfigSidebar({
|
||||||
return () => window.removeEventListener("keydown", handleEscape);
|
return () => window.removeEventListener("keydown", handleEscape);
|
||||||
}, [open, onOpenChange]);
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atTop = el.scrollTop <= 2;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||||
|
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
|
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
|
||||||
|
|
||||||
const suggestedModels = useMemo(() => {
|
const suggestedModels = useMemo(() => {
|
||||||
|
|
@ -134,13 +132,20 @@ export function ImageConfigSidebar({
|
||||||
return "Edit Image Model";
|
return "Edit Image Model";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSubtitle = () => {
|
||||||
|
if (mode === "create") return "Set up a new image generation provider";
|
||||||
|
if (isAutoMode) return "Automatically routes requests across providers";
|
||||||
|
if (isGlobal) return "Read-only global configuration";
|
||||||
|
return "Update your image model settings";
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
const result = await createConfig({
|
const result = await createConfig({
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
provider: formData.provider,
|
provider: formData.provider as ImageGenProvider,
|
||||||
model_name: formData.model_name,
|
model_name: formData.model_name,
|
||||||
api_key: formData.api_key,
|
api_key: formData.api_key,
|
||||||
api_base: formData.api_base || undefined,
|
api_base: formData.api_base || undefined,
|
||||||
|
|
@ -148,7 +153,6 @@ export function ImageConfigSidebar({
|
||||||
description: formData.description || undefined,
|
description: formData.description || undefined,
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
});
|
});
|
||||||
// Set as active image model
|
|
||||||
if (result?.id) {
|
if (result?.id) {
|
||||||
await updatePreferences({
|
await updatePreferences({
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
|
|
@ -163,7 +167,7 @@ export function ImageConfigSidebar({
|
||||||
data: {
|
data: {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
description: formData.description || undefined,
|
description: formData.description || undefined,
|
||||||
provider: formData.provider,
|
provider: formData.provider as ImageGenProvider,
|
||||||
model_name: formData.model_name,
|
model_name: formData.model_name,
|
||||||
api_key: formData.api_key,
|
api_key: formData.api_key,
|
||||||
api_base: formData.api_base || undefined,
|
api_base: formData.api_base || undefined,
|
||||||
|
|
@ -214,126 +218,96 @@ export function ImageConfigSidebar({
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
const sidebarContent = (
|
const dialogContent = (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm"
|
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ x: "100%", opacity: 0 }}
|
initial={{ opacity: 0, scale: 0.96 }}
|
||||||
animate={{ x: 0, opacity: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ x: "100%", opacity: 0 }}
|
exit={{ opacity: 0, scale: 0.96 }}
|
||||||
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||||
className={cn(
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
|
||||||
"fixed right-0 top-0 z-50 h-full w-full sm:w-[480px] lg:w-[540px]",
|
|
||||||
"bg-background border-l border-border/50 shadow-2xl",
|
|
||||||
"flex flex-col"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between px-6 py-4 border-b border-border/50",
|
"relative w-full max-w-lg h-[85vh]",
|
||||||
isAutoMode
|
"rounded-xl bg-background shadow-2xl ring-1 ring-border/50",
|
||||||
? "bg-gradient-to-r from-violet-500/10 to-purple-500/10"
|
"dark:bg-neutral-900 dark:ring-white/5",
|
||||||
: "bg-gradient-to-r from-teal-500/10 to-cyan-500/10"
|
"flex flex-col overflow-hidden"
|
||||||
)}
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") onOpenChange(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
{/* Header */}
|
||||||
<div
|
<div className="flex items-start justify-between px-6 pt-6 pb-4">
|
||||||
className={cn(
|
<div className="space-y-1 pr-8">
|
||||||
"flex items-center justify-center size-10 rounded-xl",
|
<div className="flex items-center gap-2">
|
||||||
isAutoMode
|
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||||
? "bg-gradient-to-br from-violet-500 to-purple-600"
|
{isAutoMode && (
|
||||||
: "bg-gradient-to-br from-teal-500 to-cyan-600"
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isAutoMode ? (
|
|
||||||
<Shuffle className="size-5 text-white" />
|
|
||||||
) : (
|
|
||||||
<ImageIcon className="size-5 text-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base sm:text-lg font-semibold">{getTitle()}</h2>
|
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
|
||||||
{isAutoMode ? (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="gap-1 text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
|
||||||
>
|
|
||||||
<Zap className="size-3" />
|
|
||||||
Recommended
|
Recommended
|
||||||
</Badge>
|
</Badge>
|
||||||
) : isGlobal ? (
|
)}
|
||||||
<Badge variant="secondary" className="gap-1 text-xs">
|
{isGlobal && !isAutoMode && mode !== "create" && (
|
||||||
<Globe className="size-3" />
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
Global
|
Global
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
|
||||||
{config && !isAutoMode && (
|
|
||||||
<span className="text-xs text-muted-foreground">{config.model_name}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||||
|
{config && !isAutoMode && mode !== "create" && (
|
||||||
|
<p className="text-xs font-mono text-muted-foreground/70">
|
||||||
|
{config.model_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="absolute right-4 top-4 h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="h-8 w-8 rounded-full"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Scrollable content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div
|
||||||
<div className="p-6">
|
ref={scrollRef}
|
||||||
{/* Auto mode */}
|
onScroll={handleScroll}
|
||||||
|
className="flex-1 overflow-y-auto px-6 py-5"
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isAutoMode && (
|
{isAutoMode && (
|
||||||
<>
|
<Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
|
||||||
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5">
|
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
||||||
<Shuffle className="size-4 text-violet-500" />
|
Auto mode distributes image generation requests across all configured
|
||||||
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
providers for optimal performance and rate limit protection.
|
||||||
Auto mode distributes image generation requests across all configured
|
</AlertDescription>
|
||||||
providers for optimal performance and rate limit protection.
|
</Alert>
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<div className="flex gap-3 pt-4 border-t border-border/50">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
|
|
||||||
onClick={handleUseGlobalConfig}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Loading..." : "Use Auto Mode"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Global config (read-only) */}
|
|
||||||
{isGlobal && !isAutoMode && config && (
|
{isGlobal && !isAutoMode && config && (
|
||||||
<>
|
<>
|
||||||
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5">
|
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
|
||||||
<AlertCircle className="size-4 text-amber-500" />
|
<AlertCircle className="size-4 text-amber-500" />
|
||||||
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
||||||
Global configurations are read-only. To customize, create a new model.
|
Global configurations are read-only. To customize, create a new model.
|
||||||
|
|
@ -372,29 +346,11 @@ export function ImageConfigSidebar({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 pt-6 border-t border-border/50 mt-6">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1 gap-2"
|
|
||||||
onClick={handleUseGlobalConfig}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Loading..." : "Use This Model"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create / Edit form */}
|
|
||||||
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
|
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Name */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">Name *</Label>
|
<Label className="text-sm font-medium">Name *</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -404,7 +360,6 @@ export function ImageConfigSidebar({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">Description</Label>
|
<Label className="text-sm font-medium">Description</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -418,7 +373,6 @@ export function ImageConfigSidebar({
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Provider */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">Provider *</Label>
|
<Label className="text-sm font-medium">Provider *</Label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -430,20 +384,16 @@ export function ImageConfigSidebar({
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a provider" />
|
<SelectValue placeholder="Select a provider" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="bg-muted dark:border-neutral-700">
|
||||||
{IMAGE_GEN_PROVIDERS.map((p) => (
|
{IMAGE_GEN_PROVIDERS.map((p) => (
|
||||||
<SelectItem key={p.value} value={p.value}>
|
<SelectItem key={p.value} value={p.value} description={p.example}>
|
||||||
<div className="flex flex-col">
|
{p.label}
|
||||||
<span className="font-medium">{p.label}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{p.example}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Name */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">Model Name *</Label>
|
<Label className="text-sm font-medium">Model Name *</Label>
|
||||||
{suggestedModels.length > 0 ? (
|
{suggestedModels.length > 0 ? (
|
||||||
|
|
@ -452,14 +402,17 @@ export function ImageConfigSidebar({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className="w-full justify-between font-normal"
|
className="w-full justify-between font-normal bg-transparent"
|
||||||
>
|
>
|
||||||
{formData.model_name || "Select or type a model..."}
|
{formData.model_name || "Select or type a model..."}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-full p-0" align="start">
|
<PopoverContent
|
||||||
<Command>
|
className="w-full p-0 bg-muted dark:border-neutral-700"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command className="bg-transparent">
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search or type model..."
|
placeholder="Search or type model..."
|
||||||
value={formData.model_name}
|
value={formData.model_name}
|
||||||
|
|
@ -513,11 +466,8 @@ export function ImageConfigSidebar({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Key */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium flex items-center gap-1.5">
|
<Label className="text-sm font-medium">API Key *</Label>
|
||||||
<Key className="h-3.5 w-3.5" /> API Key *
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="sk-..."
|
placeholder="sk-..."
|
||||||
|
|
@ -526,7 +476,6 @@ export function ImageConfigSidebar({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Base */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">API Base URL</Label>
|
<Label className="text-sm font-medium">API Base URL</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -536,7 +485,6 @@ export function ImageConfigSidebar({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Azure API Version */}
|
|
||||||
{formData.provider === "AZURE_OPENAI" && (
|
{formData.provider === "AZURE_OPENAI" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">API Version (Azure)</Label>
|
<Label className="text-sm font-medium">API Version (Azure)</Label>
|
||||||
|
|
@ -549,28 +497,56 @@ export function ImageConfigSidebar({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-3 pt-4 border-t">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1 gap-2 bg-gradient-to-r from-teal-500 to-cyan-600 hover:from-teal-600 hover:to-cyan-700"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSubmitting || !isFormValid}
|
|
||||||
>
|
|
||||||
{isSubmitting ? <Spinner size="sm" className="mr-2" /> : null}
|
|
||||||
{mode === "edit" ? "Save Changes" : "Create & Use"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fixed footer */}
|
||||||
|
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="text-sm h-9"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{mode === "create" || (mode === "edit" && !isGlobal) ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || !isFormValid}
|
||||||
|
className="text-sm h-9 min-w-[120px]"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" />
|
||||||
|
{mode === "edit" ? "Saving" : "Creating"}
|
||||||
|
</>
|
||||||
|
) : mode === "edit" ? (
|
||||||
|
"Save Changes"
|
||||||
|
) : (
|
||||||
|
"Create & Use"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : isAutoMode ? (
|
||||||
|
<Button
|
||||||
|
className="text-sm h-9 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
|
||||||
|
onClick={handleUseGlobalConfig}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Loading..." : "Use Auto Mode"}
|
||||||
|
</Button>
|
||||||
|
) : isGlobal && config ? (
|
||||||
|
<Button
|
||||||
|
className="text-sm h-9 gap-2"
|
||||||
|
onClick={handleUseGlobalConfig}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Loading..." : "Use This Model"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -578,5 +554,5 @@ export function ImageConfigSidebar({
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
|
|
||||||
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null;
|
return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null;
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { AlertCircle, Bot, ChevronRight, Globe, Shuffle, User, X, Zap } from "lucide-react";
|
import { AlertCircle, X, Zap } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,13 +15,15 @@ import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-c
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import type {
|
import type {
|
||||||
GlobalNewLLMConfig,
|
GlobalNewLLMConfig,
|
||||||
|
LiteLLMProvider,
|
||||||
NewLLMConfigPublic,
|
NewLLMConfigPublic,
|
||||||
} from "@/contracts/types/new-llm-config.types";
|
} from "@/contracts/types/new-llm-config.types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ModelConfigSidebarProps {
|
interface ModelConfigDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
|
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
|
||||||
|
|
@ -30,28 +32,34 @@ interface ModelConfigSidebarProps {
|
||||||
mode: "create" | "edit" | "view";
|
mode: "create" | "edit" | "view";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelConfigSidebar({
|
export function ModelConfigDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
config,
|
config,
|
||||||
isGlobal,
|
isGlobal,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
mode,
|
mode,
|
||||||
}: ModelConfigSidebarProps) {
|
}: ModelConfigDialogProps) {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Handle SSR - only render portal on client
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Mutations - use mutateAsync from the atom value
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atTop = el.scrollTop <= 2;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||||
|
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
|
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
|
||||||
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
|
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
|
||||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||||
|
|
||||||
// Handle escape key
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && open) {
|
if (e.key === "Escape" && open) {
|
||||||
|
|
@ -62,10 +70,8 @@ export function ModelConfigSidebar({
|
||||||
return () => window.removeEventListener("keydown", handleEscape);
|
return () => window.removeEventListener("keydown", handleEscape);
|
||||||
}, [open, onOpenChange]);
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
// Check if this is Auto mode
|
|
||||||
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
|
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
|
||||||
|
|
||||||
// Get title based on mode
|
|
||||||
const getTitle = () => {
|
const getTitle = () => {
|
||||||
if (mode === "create") return "Add New Configuration";
|
if (mode === "create") return "Add New Configuration";
|
||||||
if (isAutoMode) return "Auto Mode (Fastest)";
|
if (isAutoMode) return "Auto Mode (Fastest)";
|
||||||
|
|
@ -73,19 +79,23 @@ export function ModelConfigSidebar({
|
||||||
return "Edit Configuration";
|
return "Edit Configuration";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle form submit
|
const getSubtitle = () => {
|
||||||
|
if (mode === "create") return "Set up a new LLM provider for this search space";
|
||||||
|
if (isAutoMode) return "Automatically routes requests across providers";
|
||||||
|
if (isGlobal) return "Read-only global configuration";
|
||||||
|
return "Update your configuration settings";
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (data: LLMConfigFormData) => {
|
async (data: LLMConfigFormData) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
// Create new config
|
|
||||||
const result = await createConfig({
|
const result = await createConfig({
|
||||||
...data,
|
...data,
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assign the new config to the agent role
|
|
||||||
if (result?.id) {
|
if (result?.id) {
|
||||||
await updatePreferences({
|
await updatePreferences({
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
|
|
@ -98,7 +108,6 @@ export function ModelConfigSidebar({
|
||||||
toast.success("Configuration created and assigned!");
|
toast.success("Configuration created and assigned!");
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} else if (!isGlobal && config) {
|
} else if (!isGlobal && config) {
|
||||||
// Update existing user config
|
|
||||||
await updateConfig({
|
await updateConfig({
|
||||||
id: config.id,
|
id: config.id,
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -137,7 +146,6 @@ export function ModelConfigSidebar({
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle "Use this model" for global configs
|
|
||||||
const handleUseGlobalConfig = useCallback(async () => {
|
const handleUseGlobalConfig = useCallback(async () => {
|
||||||
if (!config || !isGlobal) return;
|
if (!config || !isGlobal) return;
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
@ -160,7 +168,7 @@ export function ModelConfigSidebar({
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
const sidebarContent = (
|
const dialogContent = (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -169,93 +177,84 @@ export function ModelConfigSidebar({
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="fixed inset-0 z-[24] bg-black/20 backdrop-blur-sm"
|
className="fixed inset-0 z-[24] bg-black/50 backdrop-blur-sm"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sidebar Panel */}
|
{/* Dialog */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ x: "100%", opacity: 0 }}
|
initial={{ opacity: 0, scale: 0.96 }}
|
||||||
animate={{ x: 0, opacity: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ x: "100%", opacity: 0 }}
|
exit={{ opacity: 0, scale: 0.96 }}
|
||||||
transition={{
|
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||||
type: "spring",
|
className="fixed inset-0 z-[25] flex items-center justify-center p-4 sm:p-6"
|
||||||
damping: 30,
|
|
||||||
stiffness: 300,
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"fixed right-0 top-0 z-[25] h-full w-full sm:w-[480px] lg:w-[540px]",
|
|
||||||
"bg-background border-l border-border/50 shadow-2xl",
|
|
||||||
"flex flex-col"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between px-6 py-4 border-b border-border/50",
|
"relative w-full max-w-lg h-[85vh]",
|
||||||
isAutoMode ? "bg-gradient-to-r from-violet-500/10 to-purple-500/10" : "bg-muted/20"
|
"rounded-xl bg-background shadow-2xl ring-1 ring-border/50",
|
||||||
|
"dark:bg-neutral-900 dark:ring-white/5",
|
||||||
|
"flex flex-col overflow-hidden"
|
||||||
)}
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") onOpenChange(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
{/* Header */}
|
||||||
<div
|
<div className="flex items-start justify-between px-6 pt-6 pb-4">
|
||||||
className={cn(
|
<div className="space-y-1 pr-8">
|
||||||
"flex items-center justify-center size-10 rounded-xl",
|
<div className="flex items-center gap-2">
|
||||||
isAutoMode ? "bg-gradient-to-br from-violet-500 to-purple-600" : "bg-primary/10"
|
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||||
)}
|
{isAutoMode && (
|
||||||
>
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
{isAutoMode ? (
|
|
||||||
<Shuffle className="size-5 text-white" />
|
|
||||||
) : (
|
|
||||||
<Bot className="size-5 text-primary" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base sm:text-lg font-semibold">{getTitle()}</h2>
|
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
|
||||||
{isAutoMode ? (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="gap-1 text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
|
||||||
>
|
|
||||||
<Zap className="size-3" />
|
|
||||||
Recommended
|
Recommended
|
||||||
</Badge>
|
</Badge>
|
||||||
) : isGlobal ? (
|
)}
|
||||||
<Badge variant="secondary" className="gap-1 text-xs">
|
{isGlobal && !isAutoMode && mode !== "create" && (
|
||||||
<Globe className="size-3" />
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
Global
|
Global
|
||||||
</Badge>
|
</Badge>
|
||||||
) : mode !== "create" ? (
|
)}
|
||||||
<Badge variant="outline" className="gap-1 text-xs">
|
{!isGlobal && mode !== "create" && !isAutoMode && (
|
||||||
<User className="size-3" />
|
<Badge variant="outline" className="text-[10px]">
|
||||||
Custom
|
Custom
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
|
||||||
{config && !isAutoMode && (
|
|
||||||
<span className="text-xs text-muted-foreground">{config.model_name}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||||
|
{config && !isAutoMode && mode !== "create" && (
|
||||||
|
<p className="text-xs font-mono text-muted-foreground/70">
|
||||||
|
{config.model_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="absolute right-4 top-4 h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="h-8 w-8 rounded-full"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content - use overflow-y-auto instead of ScrollArea for better compatibility */}
|
{/* Scrollable content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div
|
||||||
<div className="p-6">
|
ref={scrollRef}
|
||||||
{/* Auto mode info banner */}
|
onScroll={handleScroll}
|
||||||
|
className="flex-1 overflow-y-auto px-6 py-5"
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isAutoMode && (
|
{isAutoMode && (
|
||||||
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5">
|
<Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
|
||||||
<Shuffle className="size-4 text-violet-500" />
|
|
||||||
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
||||||
Auto mode automatically distributes requests across all available LLM
|
Auto mode automatically distributes requests across all available LLM
|
||||||
providers to optimize performance and avoid rate limits.
|
providers to optimize performance and avoid rate limits.
|
||||||
|
|
@ -263,9 +262,8 @@ export function ModelConfigSidebar({
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Global config notice */}
|
|
||||||
{isGlobal && !isAutoMode && mode !== "create" && (
|
{isGlobal && !isAutoMode && mode !== "create" && (
|
||||||
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5">
|
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
|
||||||
<AlertCircle className="size-4 text-amber-500" />
|
<AlertCircle className="size-4 text-amber-500" />
|
||||||
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
||||||
Global configurations are read-only. To customize settings, create a new
|
Global configurations are read-only. To customize settings, create a new
|
||||||
|
|
@ -274,20 +272,17 @@ export function ModelConfigSidebar({
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
{mode === "create" ? (
|
{mode === "create" ? (
|
||||||
<LLMConfigForm
|
<LLMConfigForm
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => onOpenChange(false)}
|
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
mode="create"
|
mode="create"
|
||||||
submitLabel="Create & Use"
|
formId="model-config-form"
|
||||||
|
hideActions
|
||||||
/>
|
/>
|
||||||
) : isAutoMode && config ? (
|
) : isAutoMode && config ? (
|
||||||
// Special view for Auto mode
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Auto Mode Features */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
|
@ -339,36 +334,9 @@ export function ModelConfigSidebar({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex gap-3 pt-4 border-t border-border/50">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
|
|
||||||
onClick={handleUseGlobalConfig}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>Loading...</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
Use Auto Mode
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : isGlobal && config ? (
|
) : isGlobal && config ? (
|
||||||
// Read-only view for global configs
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Config Details */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
@ -436,43 +404,17 @@ export function ModelConfigSidebar({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex gap-3 pt-4 border-t border-border/50">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1 gap-2"
|
|
||||||
onClick={handleUseGlobalConfig}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>Loading...</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
Use This Model
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : config ? (
|
) : config ? (
|
||||||
// Edit form for user configs
|
|
||||||
<LLMConfigForm
|
<LLMConfigForm
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
initialData={{
|
initialData={{
|
||||||
name: config.name,
|
name: config.name,
|
||||||
description: config.description,
|
description: config.description,
|
||||||
provider: config.provider,
|
provider: config.provider as LiteLLMProvider,
|
||||||
custom_provider: config.custom_provider,
|
custom_provider: config.custom_provider,
|
||||||
model_name: config.model_name,
|
model_name: config.model_name,
|
||||||
api_key: config.api_key,
|
api_key: "api_key" in config ? (config.api_key as string) : "",
|
||||||
api_base: config.api_base,
|
api_base: config.api_base,
|
||||||
litellm_params: config.litellm_params,
|
litellm_params: config.litellm_params,
|
||||||
system_instructions: config.system_instructions,
|
system_instructions: config.system_instructions,
|
||||||
|
|
@ -481,13 +423,61 @@ export function ModelConfigSidebar({
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
}}
|
}}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => onOpenChange(false)}
|
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
mode="edit"
|
mode="edit"
|
||||||
submitLabel="Save Changes"
|
formId="model-config-form"
|
||||||
|
hideActions
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fixed footer */}
|
||||||
|
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="text-sm h-9"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{mode === "create" || (!isGlobal && !isAutoMode && config) ? (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="model-config-form"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="text-sm h-9 min-w-[120px]"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" />
|
||||||
|
{mode === "edit" ? "Saving" : "Creating"}
|
||||||
|
</>
|
||||||
|
) : mode === "edit" ? (
|
||||||
|
"Save Changes"
|
||||||
|
) : (
|
||||||
|
"Create & Use"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : isAutoMode ? (
|
||||||
|
<Button
|
||||||
|
className="text-sm h-9 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
|
||||||
|
onClick={handleUseGlobalConfig}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Loading..." : "Use Auto Mode"}
|
||||||
|
</Button>
|
||||||
|
) : isGlobal && config ? (
|
||||||
|
<Button
|
||||||
|
className="text-sm h-9 gap-2"
|
||||||
|
onClick={handleUseGlobalConfig}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Loading..." : "Use This Model"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -495,5 +485,5 @@ export function ModelConfigSidebar({
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
|
|
||||||
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null;
|
return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null;
|
||||||
}
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Zap } from "lucide-react";
|
import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Zap } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { type UIEvent, useCallback, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
globalImageGenConfigsAtom,
|
globalImageGenConfigsAtom,
|
||||||
|
|
@ -57,6 +57,17 @@ export function ModelSelector({
|
||||||
const [activeTab, setActiveTab] = useState<"llm" | "image">("llm");
|
const [activeTab, setActiveTab] = useState<"llm" | "image">("llm");
|
||||||
const [llmSearchQuery, setLlmSearchQuery] = useState("");
|
const [llmSearchQuery, setLlmSearchQuery] = useState("");
|
||||||
const [imageSearchQuery, setImageSearchQuery] = useState("");
|
const [imageSearchQuery, setImageSearchQuery] = useState("");
|
||||||
|
const [llmScrollPos, setLlmScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
|
const [imageScrollPos, setImageScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
|
const handleListScroll = useCallback(
|
||||||
|
(setter: typeof setLlmScrollPos) => (e: UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atTop = el.scrollTop <= 2;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||||
|
setter(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// LLM data
|
// LLM data
|
||||||
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
|
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
|
||||||
|
|
@ -253,7 +264,7 @@ export function ModelSelector({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="h-4 w-px bg-border/60 mx-0.5" />
|
<div className="h-4 w-px bg-border/60 dark:bg-white/10 mx-0.5" />
|
||||||
|
|
||||||
{/* Image section */}
|
{/* Image section */}
|
||||||
{currentImageConfig ? (
|
{currentImageConfig ? (
|
||||||
|
|
@ -280,7 +291,7 @@ export function ModelSelector({
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-muted dark:border dark:border-neutral-700 select-none"
|
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
|
||||||
align="start"
|
align="start"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
|
|
@ -289,7 +300,7 @@ export function ModelSelector({
|
||||||
onValueChange={(v) => setActiveTab(v as "llm" | "image")}
|
onValueChange={(v) => setActiveTab(v as "llm" | "image")}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<div className="border-b border-border/80 dark:border-white/5">
|
<div className="border-b border-border/80 dark:border-neutral-800">
|
||||||
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
|
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="llm"
|
value="llm"
|
||||||
|
|
@ -312,7 +323,7 @@ export function ModelSelector({
|
||||||
<TabsContent value="llm" className="mt-0">
|
<TabsContent value="llm" className="mt-0">
|
||||||
<Command
|
<Command
|
||||||
shouldFilter={false}
|
shouldFilter={false}
|
||||||
className="rounded-none rounded-b-lg relative dark:bg-muted [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
className="rounded-none rounded-b-lg relative dark:bg-neutral-900 [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||||
>
|
>
|
||||||
{totalLLMModels > 3 && (
|
{totalLLMModels > 3 && (
|
||||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||||
|
|
@ -325,7 +336,14 @@ export function ModelSelector({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
|
<CommandList
|
||||||
|
className="max-h-[300px] md:max-h-[400px] overflow-y-auto"
|
||||||
|
onScroll={handleListScroll(setLlmScrollPos)}
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to bottom, ${llmScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${llmScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, ${llmScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${llmScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CommandEmpty className="py-8 text-center">
|
<CommandEmpty className="py-8 text-center">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Bot className="size-8 text-muted-foreground" />
|
<Bot className="size-8 text-muted-foreground" />
|
||||||
|
|
@ -350,8 +368,8 @@ export function ModelSelector({
|
||||||
onSelect={() => handleSelectLLM(config)}
|
onSelect={() => handleSelectLLM(config)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||||
"hover:bg-accent/50 dark:hover:bg-white/10",
|
"hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||||
isSelected && "bg-accent/80 dark:bg-white/10",
|
isSelected && "bg-accent/80 dark:bg-white/[0.06]",
|
||||||
isAutoMode && ""
|
isAutoMode && ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -426,8 +444,8 @@ export function ModelSelector({
|
||||||
onSelect={() => handleSelectLLM(config)}
|
onSelect={() => handleSelectLLM(config)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||||
"hover:bg-accent/50 dark:hover:bg-white/10",
|
"hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||||
isSelected && "bg-accent/80 dark:bg-white/10"
|
isSelected && "bg-accent/80 dark:bg-white/[0.06]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between w-full gap-2">
|
<div className="flex items-center justify-between w-full gap-2">
|
||||||
|
|
@ -471,11 +489,11 @@ export function ModelSelector({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add New LLM Config */}
|
{/* Add New LLM Config */}
|
||||||
<div className="p-2 bg-muted/20 dark:bg-muted">
|
<div className="p-2 bg-muted/20 dark:bg-neutral-900">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/10"
|
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/[0.06]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onAddNewLLM();
|
onAddNewLLM();
|
||||||
|
|
@ -493,7 +511,7 @@ export function ModelSelector({
|
||||||
<TabsContent value="image" className="mt-0">
|
<TabsContent value="image" className="mt-0">
|
||||||
<Command
|
<Command
|
||||||
shouldFilter={false}
|
shouldFilter={false}
|
||||||
className="rounded-none rounded-b-lg dark:bg-muted [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
className="rounded-none rounded-b-lg dark:bg-neutral-900 [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||||
>
|
>
|
||||||
{totalImageModels > 3 && (
|
{totalImageModels > 3 && (
|
||||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||||
|
|
@ -505,7 +523,14 @@ export function ModelSelector({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
|
<CommandList
|
||||||
|
className="max-h-[300px] md:max-h-[400px] overflow-y-auto"
|
||||||
|
onScroll={handleListScroll(setImageScrollPos)}
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to bottom, ${imageScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${imageScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, ${imageScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${imageScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CommandEmpty className="py-8 text-center">
|
<CommandEmpty className="py-8 text-center">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<ImageIcon className="size-8 text-muted-foreground" />
|
<ImageIcon className="size-8 text-muted-foreground" />
|
||||||
|
|
@ -528,8 +553,8 @@ export function ModelSelector({
|
||||||
value={`img-g-${config.id}`}
|
value={`img-g-${config.id}`}
|
||||||
onSelect={() => handleSelectImage(config.id)}
|
onSelect={() => handleSelectImage(config.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10",
|
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||||
isSelected && "bg-accent/80 dark:bg-white/10",
|
isSelected && "bg-accent/80 dark:bg-white/[0.06]",
|
||||||
isAuto && ""
|
isAuto && ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -593,8 +618,8 @@ export function ModelSelector({
|
||||||
value={`img-u-${config.id}`}
|
value={`img-u-${config.id}`}
|
||||||
onSelect={() => handleSelectImage(config.id)}
|
onSelect={() => handleSelectImage(config.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10",
|
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||||
isSelected && "bg-accent/80 dark:bg-white/10"
|
isSelected && "bg-accent/80 dark:bg-white/[0.06]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
|
@ -634,11 +659,11 @@ export function ModelSelector({
|
||||||
|
|
||||||
{/* Add New Image Config */}
|
{/* Add New Image Config */}
|
||||||
{onAddNewImage && (
|
{onAddNewImage && (
|
||||||
<div className="p-2 bg-muted/20 dark:bg-muted">
|
<div className="p-2 bg-muted/20 dark:bg-neutral-900">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/10"
|
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/[0.06]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onAddNewImage();
|
onAddNewImage();
|
||||||
|
|
|
||||||
|
|
@ -334,7 +334,7 @@ function ReportPanelContent({
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="start"
|
align="start"
|
||||||
className={`min-w-[180px] bg-muted dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`}
|
className={`min-w-[180px] dark:bg-neutral-900 dark:border dark:border-white/5${insideDrawer ? " z-[100]" : ""}`}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem onClick={() => handleExport("md")}>
|
<DropdownMenuItem onClick={() => handleExport("md")}>
|
||||||
Download Markdown
|
Download Markdown
|
||||||
|
|
@ -371,7 +371,7 @@ function ReportPanelContent({
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="start"
|
align="start"
|
||||||
className={`min-w-[120px] bg-muted dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`}
|
className={`min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5${insideDrawer ? " z-[100]" : ""}`}
|
||||||
>
|
>
|
||||||
{versions.map((v, i) => (
|
{versions.map((v, i) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|
|
||||||
|
|
@ -578,10 +578,7 @@ function RolesContent({
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
Delete Role
|
Delete Role
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,7 @@
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||||
Bot,
|
|
||||||
Check,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronsUpDown,
|
|
||||||
Key,
|
|
||||||
MessageSquareQuote,
|
|
||||||
Rocket,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
@ -88,6 +79,8 @@ interface LLMConfigFormProps {
|
||||||
submitLabel?: string;
|
submitLabel?: string;
|
||||||
showAdvanced?: boolean;
|
showAdvanced?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
formId?: string;
|
||||||
|
hideActions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LLMConfigForm({
|
export function LLMConfigForm({
|
||||||
|
|
@ -100,6 +93,8 @@ export function LLMConfigForm({
|
||||||
submitLabel,
|
submitLabel,
|
||||||
showAdvanced = true,
|
showAdvanced = true,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
formId,
|
||||||
|
hideActions = false,
|
||||||
}: LLMConfigFormProps) {
|
}: LLMConfigFormProps) {
|
||||||
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
|
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
|
||||||
defaultSystemInstructionsAtom
|
defaultSystemInstructionsAtom
|
||||||
|
|
@ -164,11 +159,10 @@ export function LLMConfigForm({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
|
<form id={formId} onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||||
{/* Model Configuration Section */}
|
{/* Model Configuration Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground">
|
<div className="text-xs sm:text-sm font-medium text-muted-foreground">
|
||||||
<Bot className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
|
||||||
Model Configuration
|
Model Configuration
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -179,16 +173,9 @@ export function LLMConfigForm({
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm">
|
<FormLabel className="text-xs sm:text-sm">Configuration Name</FormLabel>
|
||||||
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
|
|
||||||
Configuration Name
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input placeholder="e.g., My GPT-4 Agent" {...field} />
|
||||||
placeholder="e.g., My GPT-4 Agent"
|
|
||||||
className="transition-all focus-visible:ring-violet-500/50"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -224,19 +211,18 @@ export function LLMConfigForm({
|
||||||
<FormLabel className="text-xs sm:text-sm">LLM Provider</FormLabel>
|
<FormLabel className="text-xs sm:text-sm">LLM Provider</FormLabel>
|
||||||
<Select value={field.value} onValueChange={handleProviderChange}>
|
<Select value={field.value} onValueChange={handleProviderChange}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="transition-all focus:ring-violet-500/50">
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a provider" />
|
<SelectValue placeholder="Select a provider" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent className="max-h-[300px]">
|
<SelectContent className="max-h-[300px] bg-muted dark:border-neutral-700">
|
||||||
{LLM_PROVIDERS.map((provider) => (
|
{LLM_PROVIDERS.map((provider) => (
|
||||||
<SelectItem key={provider.value} value={provider.value}>
|
<SelectItem
|
||||||
<div className="flex flex-col py-0.5">
|
key={provider.value}
|
||||||
<span className="font-medium">{provider.label}</span>
|
value={provider.value}
|
||||||
<span className="text-xs text-muted-foreground">
|
description={provider.description}
|
||||||
{provider.description}
|
>
|
||||||
</span>
|
{provider.label}
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -290,7 +276,7 @@ export function LLMConfigForm({
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={modelComboboxOpen}
|
aria-expanded={modelComboboxOpen}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between font-normal",
|
"w-full justify-between font-normal bg-transparent",
|
||||||
!field.value && "text-muted-foreground"
|
!field.value && "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -299,8 +285,11 @@ export function LLMConfigForm({
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-full p-0" align="start">
|
<PopoverContent
|
||||||
<Command shouldFilter={false}>
|
className="w-full p-0 bg-muted dark:border-neutral-700"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command shouldFilter={false} className="bg-transparent">
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder={selectedProvider?.example || "Type model name..."}
|
placeholder={selectedProvider?.example || "Type model name..."}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
|
|
@ -371,10 +360,7 @@ export function LLMConfigForm({
|
||||||
name="api_key"
|
name="api_key"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm">
|
<FormLabel className="text-xs sm:text-sm">API Key</FormLabel>
|
||||||
<Key className="h-3.5 w-3.5 text-amber-500" />
|
|
||||||
API Key
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
|
|
@ -460,10 +446,7 @@ export function LLMConfigForm({
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<span>Advanced Parameters</span>
|
||||||
<Sparkles className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
|
||||||
Advanced Parameters
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 transition-transform duration-200",
|
"h-4 w-4 transition-transform duration-200",
|
||||||
|
|
@ -501,10 +484,7 @@ export function LLMConfigForm({
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<span>System Instructions</span>
|
||||||
<MessageSquareQuote className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
|
||||||
System Instructions
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 transition-transform duration-200",
|
"h-4 w-4 transition-transform duration-200",
|
||||||
|
|
@ -575,42 +555,43 @@ export function LLMConfigForm({
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{!hideActions && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex gap-3 pt-4",
|
"flex gap-3 pt-4",
|
||||||
compact ? "justify-end" : "justify-center sm:justify-end"
|
compact ? "justify-end" : "justify-center sm:justify-end"
|
||||||
)}
|
|
||||||
>
|
|
||||||
{onCancel && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="text-xs sm:text-sm h-9 sm:h-10"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="gap-2 min-w-[140px] sm:min-w-[160px] text-xs sm:text-sm h-9 sm:h-10"
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<Spinner size="sm" />
|
|
||||||
{mode === "edit" ? "Updating..." : "Creating"}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{!compact && <Rocket className="h-3.5 w-3.5 sm:h-4 sm:w-4" />}
|
|
||||||
{submitLabel ?? (mode === "edit" ? "Update Configuration" : "Create Configuration")}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
>
|
||||||
</div>
|
{onCancel && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="text-xs sm:text-sm h-9 sm:h-10"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="gap-2 min-w-[140px] sm:min-w-[160px] text-xs sm:text-sm h-9 sm:h-10"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" />
|
||||||
|
{mode === "edit" ? "Updating..." : "Creating"}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{submitLabel ??
|
||||||
|
(mode === "edit" ? "Update Configuration" : "Create Configuration")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react";
|
import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
|
|
@ -241,12 +241,7 @@ export function DocumentUploadTab({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0"
|
|
||||||
>
|
|
||||||
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0">
|
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0">
|
||||||
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
|
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
|
||||||
|
|
@ -287,14 +282,10 @@ export function DocumentUploadTab({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isDragActive ? (
|
) : isDragActive ? (
|
||||||
<motion.div
|
<div className="flex flex-col items-center gap-2 sm:gap-4">
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
className="flex flex-col items-center gap-2 sm:gap-4"
|
|
||||||
>
|
|
||||||
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" />
|
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" />
|
||||||
<p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p>
|
<p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p>
|
||||||
</motion.div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-2 sm:gap-4">
|
<div className="flex flex-col items-center gap-2 sm:gap-4">
|
||||||
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" />
|
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" />
|
||||||
|
|
@ -329,124 +320,102 @@ export function DocumentUploadTab({
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
{files.length > 0 && (
|
||||||
{files.length > 0 && (
|
<Card className={cardClass}>
|
||||||
<motion.div
|
<CardHeader className="p-4 sm:p-6">
|
||||||
initial={{ opacity: 0, height: 0 }}
|
<div className="flex items-center justify-between gap-2">
|
||||||
animate={{ opacity: 1, height: "auto" }}
|
<div className="min-w-0 flex-1">
|
||||||
exit={{ opacity: 0, height: 0 }}
|
<CardTitle className="text-base sm:text-2xl">
|
||||||
transition={{ duration: 0.3 }}
|
{t("selected_files", { count: files.length })}
|
||||||
>
|
</CardTitle>
|
||||||
<Card className={cardClass}>
|
<CardDescription className="text-xs sm:text-sm">
|
||||||
<CardHeader className="p-4 sm:p-6">
|
{t("total_size")}: {formatFileSize(totalFileSize)}
|
||||||
<div className="flex items-center justify-between gap-2">
|
</CardDescription>
|
||||||
<div className="min-w-0 flex-1">
|
</div>
|
||||||
<CardTitle className="text-base sm:text-2xl">
|
<Button
|
||||||
{t("selected_files", { count: files.length })}
|
variant="outline"
|
||||||
</CardTitle>
|
size="sm"
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
className="text-xs sm:text-sm shrink-0"
|
||||||
{t("total_size")}: {formatFileSize(totalFileSize)}
|
onClick={() => setFiles([])}
|
||||||
</CardDescription>
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
{t("clear_all")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 sm:p-6 pt-0">
|
||||||
|
<div className="space-y-2 sm:space-y-3 max-h-[250px] sm:max-h-[400px] overflow-y-auto">
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={`${file.name}-${index}`}
|
||||||
|
className={`flex items-center justify-between p-2 sm:p-4 rounded-lg border border-border ${cardClass} hover:bg-slate-400/10 dark:hover:bg-white/10 transition-colors`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm sm:text-base font-medium truncate">{file.name}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{file.type || "Unknown type"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
className="text-xs sm:text-sm shrink-0"
|
onClick={() => setFiles((prev) => prev.filter((_, i) => i !== index))}
|
||||||
onClick={() => setFiles([])}
|
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
|
className="h-8 w-8"
|
||||||
>
|
>
|
||||||
{t("clear_all")}
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
))}
|
||||||
<CardContent className="p-4 sm:p-6 pt-0">
|
</div>
|
||||||
<div className="space-y-2 sm:space-y-3 max-h-[250px] sm:max-h-[400px] overflow-y-auto">
|
|
||||||
<AnimatePresence>
|
|
||||||
{files.map((file, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={`${file.name}-${index}`}
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: 20 }}
|
|
||||||
className={`flex items-center justify-between p-2 sm:p-4 rounded-lg border border-border ${cardClass} hover:bg-slate-400/10 dark:hover:bg-white/10 transition-colors`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
||||||
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm sm:text-base font-medium truncate">{file.name}</p>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{file.type || "Unknown type"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setFiles((prev) => prev.filter((_, i) => i !== index))}
|
|
||||||
disabled={isUploading}
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<motion.div
|
<div className="mt-3 sm:mt-6 space-y-2 sm:space-y-3">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<Separator className="bg-border" />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<div className="space-y-2">
|
||||||
className="mt-3 sm:mt-6 space-y-2 sm:space-y-3"
|
<div className="flex items-center justify-between text-xs sm:text-sm">
|
||||||
>
|
<span>{t("uploading_files")}</span>
|
||||||
<Separator className="bg-border" />
|
<span>{Math.round(uploadProgress)}%</span>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<div className="flex items-center justify-between text-xs sm:text-sm">
|
<Progress value={uploadProgress} className="h-2" />
|
||||||
<span>{t("uploading_files")}</span>
|
</div>
|
||||||
<span>{Math.round(uploadProgress)}%</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<Progress value={uploadProgress} className="h-2" />
|
|
||||||
</div>
|
<div className="mt-3 sm:mt-6">
|
||||||
</motion.div>
|
<SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 sm:mt-6">
|
||||||
|
<Button
|
||||||
|
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={isUploading || files.length === 0}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
{t("uploading")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
|
{t("upload_button", { count: files.length })}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</Button>
|
||||||
<div className="mt-3 sm:mt-6">
|
</div>
|
||||||
<SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} />
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
)}
|
||||||
<motion.div
|
|
||||||
className="mt-3 sm:mt-6"
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium"
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={isUploading || files.length === 0}
|
|
||||||
>
|
|
||||||
{isUploading ? (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Spinner size="sm" />
|
|
||||||
{t("uploading")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
|
|
||||||
{t("upload_button", { count: files.length })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<Accordion
|
<Accordion
|
||||||
type="single"
|
type="single"
|
||||||
|
|
@ -479,6 +448,6 @@ export function DocumentUploadTab({
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ function AlertDialogOverlay({
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
data-slot="alert-dialog-overlay"
|
data-slot="alert-dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -45,7 +45,7 @@ function AlertDialogContent({
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
data-slot="alert-dialog-content"
|
data-slot="alert-dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"bg-background dark:bg-neutral-900 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl ring-1 ring-border/50 dark:ring-white/5 p-6 shadow-2xl duration-200 sm:max-w-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -113,7 +113,7 @@ function AlertDialogCancel({
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Cancel
|
<AlertDialogPrimitive.Cancel
|
||||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
className={cn(buttonVariants({ variant: "secondary" }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
|
||||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
|
||||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
|
||||||
return (
|
|
||||||
<ol
|
|
||||||
data-slot="breadcrumb-list"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
data-slot="breadcrumb-item"
|
|
||||||
className={cn("inline-flex items-center gap-1.5", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbLink({
|
|
||||||
asChild,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"a"> & {
|
|
||||||
asChild?: boolean;
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot : "a";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="breadcrumb-link"
|
|
||||||
className={cn("hover:text-foreground transition-colors", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="breadcrumb-page"
|
|
||||||
aria-disabled="true"
|
|
||||||
aria-current="page"
|
|
||||||
className={cn("text-foreground font-normal", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
data-slot="breadcrumb-separator"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("[&>svg]:size-3.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? <ChevronRight />}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="breadcrumb-ellipsis"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("flex size-9 items-center justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
<span className="sr-only">More</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
BreadcrumbEllipsis,
|
|
||||||
};
|
|
||||||
|
|
@ -88,11 +88,14 @@ function Calendar({
|
||||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||||
defaultClassNames.day
|
defaultClassNames.day
|
||||||
),
|
),
|
||||||
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
|
range_start: cn(
|
||||||
|
"rounded-l-md bg-accent dark:bg-neutral-700",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
range_end: cn("rounded-r-md bg-accent dark:bg-neutral-700", defaultClassNames.range_end),
|
||||||
today: cn(
|
today: cn(
|
||||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
"bg-accent dark:bg-neutral-700 text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
defaultClassNames.today
|
defaultClassNames.today
|
||||||
),
|
),
|
||||||
outside: cn(
|
outside: cn(
|
||||||
|
|
@ -164,7 +167,7 @@ function CalendarDayButton({
|
||||||
data-range-end={modifiers.range_end}
|
data-range-end={modifiers.range_end}
|
||||||
data-range-middle={modifiers.range_middle}
|
data-range-middle={modifiers.range_middle}
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent dark:data-[range-middle=true]:bg-neutral-700 data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:bg-neutral-700 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||||
defaultClassNames.day,
|
defaultClassNames.day,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ function ContextMenuSubContent({
|
||||||
<ContextMenuPrimitive.SubContent
|
<ContextMenuPrimitive.SubContent
|
||||||
data-slot="context-menu-sub-content"
|
data-slot="context-menu-sub-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
"bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -83,7 +83,7 @@ function ContextMenuContent({
|
||||||
<ContextMenuPrimitive.Content
|
<ContextMenuPrimitive.Content
|
||||||
data-slot="context-menu-content"
|
data-slot="context-menu-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
"bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-md",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -107,8 +107,7 @@ function ContextMenuItem({
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -190,7 +189,7 @@ function ContextMenuSeparator({
|
||||||
return (
|
return (
|
||||||
<ContextMenuPrimitive.Separator
|
<ContextMenuPrimitive.Separator
|
||||||
data-slot="context-menu-separator"
|
data-slot="context-menu-separator"
|
||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
className={cn("bg-border dark:bg-neutral-700 -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 ring-1 ring-border/50 dark:ring-white/5 bg-background dark:bg-neutral-900 p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-xl focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 z-50 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-4 top-4 z-50 h-8 w-8 rounded-full inline-flex items-center justify-center text-muted-foreground transition-colors hover:text-foreground hover:bg-accent focus:outline-none disabled:pointer-events-none">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ function DropdownMenuContent({
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
"bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border dark:border-neutral-700 p-1 shadow-md",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -61,7 +61,7 @@ function DropdownMenuItem({
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -149,7 +149,7 @@ function DropdownMenuSeparator({
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
data-slot="dropdown-menu-separator"
|
data-slot="dropdown-menu-separator"
|
||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
className={cn("bg-border dark:bg-neutral-700 -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -201,7 +201,7 @@ function DropdownMenuSubContent({
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
"bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { KEYS } from "platejs";
|
import { KEYS } from "platejs";
|
||||||
import { useEditorReadOnly, useEditorRef } from "platejs/react";
|
import { useEditorReadOnly, useEditorRef } from "platejs/react";
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { useEditorSave } from "@/components/editor/editor-save-context";
|
import { useEditorSave } from "@/components/editor/editor-save-context";
|
||||||
|
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
|
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
|
||||||
|
|
||||||
|
|
@ -26,11 +26,20 @@ import { ModeToolbarButton } from "./mode-toolbar-button";
|
||||||
import { ToolbarButton, ToolbarGroup } from "./toolbar";
|
import { ToolbarButton, ToolbarGroup } from "./toolbar";
|
||||||
import { TurnIntoToolbarButton } from "./turn-into-toolbar-button";
|
import { TurnIntoToolbarButton } from "./turn-into-toolbar-button";
|
||||||
|
|
||||||
|
function TooltipWithShortcut({ label, keys }: { label: string; keys: string[] }) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center">
|
||||||
|
{label}
|
||||||
|
<ShortcutKbd keys={keys} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function FixedToolbarButtons() {
|
export function FixedToolbarButtons() {
|
||||||
const readOnly = useEditorReadOnly();
|
const readOnly = useEditorReadOnly();
|
||||||
const editor = useEditorRef();
|
const editor = useEditorRef();
|
||||||
const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave();
|
const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave();
|
||||||
const { shortcut } = usePlatformShortcut();
|
const { shortcutKeys } = usePlatformShortcut();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center">
|
<div className="flex w-full items-center">
|
||||||
|
|
@ -40,7 +49,7 @@ export function FixedToolbarButtons() {
|
||||||
<>
|
<>
|
||||||
<ToolbarGroup>
|
<ToolbarGroup>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
tooltip={`Undo ${shortcut("Mod", "Z")}`}
|
tooltip={<TooltipWithShortcut label="Undo" keys={shortcutKeys("Mod", "Z")} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor.undo();
|
editor.undo();
|
||||||
editor.tf.focus();
|
editor.tf.focus();
|
||||||
|
|
@ -50,7 +59,9 @@ export function FixedToolbarButtons() {
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
tooltip={`Redo ${shortcut("Mod", "Shift", "Z")}`}
|
tooltip={
|
||||||
|
<TooltipWithShortcut label="Redo" keys={shortcutKeys("Mod", "Shift", "Z")} />
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor.redo();
|
editor.redo();
|
||||||
editor.tf.focus();
|
editor.tf.focus();
|
||||||
|
|
@ -66,35 +77,51 @@ export function FixedToolbarButtons() {
|
||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
|
|
||||||
<ToolbarGroup>
|
<ToolbarGroup>
|
||||||
<MarkToolbarButton nodeType={KEYS.bold} tooltip={`Bold ${shortcut("Mod", "B")}`}>
|
<MarkToolbarButton
|
||||||
|
nodeType={KEYS.bold}
|
||||||
|
tooltip={<TooltipWithShortcut label="Bold" keys={shortcutKeys("Mod", "B")} />}
|
||||||
|
>
|
||||||
<BoldIcon />
|
<BoldIcon />
|
||||||
</MarkToolbarButton>
|
</MarkToolbarButton>
|
||||||
|
|
||||||
<MarkToolbarButton nodeType={KEYS.italic} tooltip={`Italic ${shortcut("Mod", "I")}`}>
|
<MarkToolbarButton
|
||||||
|
nodeType={KEYS.italic}
|
||||||
|
tooltip={<TooltipWithShortcut label="Italic" keys={shortcutKeys("Mod", "I")} />}
|
||||||
|
>
|
||||||
<ItalicIcon />
|
<ItalicIcon />
|
||||||
</MarkToolbarButton>
|
</MarkToolbarButton>
|
||||||
|
|
||||||
<MarkToolbarButton
|
<MarkToolbarButton
|
||||||
nodeType={KEYS.underline}
|
nodeType={KEYS.underline}
|
||||||
tooltip={`Underline ${shortcut("Mod", "U")}`}
|
tooltip={<TooltipWithShortcut label="Underline" keys={shortcutKeys("Mod", "U")} />}
|
||||||
>
|
>
|
||||||
<UnderlineIcon />
|
<UnderlineIcon />
|
||||||
</MarkToolbarButton>
|
</MarkToolbarButton>
|
||||||
|
|
||||||
<MarkToolbarButton
|
<MarkToolbarButton
|
||||||
nodeType={KEYS.strikethrough}
|
nodeType={KEYS.strikethrough}
|
||||||
tooltip={`Strikethrough ${shortcut("Mod", "Shift", "X")}`}
|
tooltip={
|
||||||
|
<TooltipWithShortcut
|
||||||
|
label="Strikethrough"
|
||||||
|
keys={shortcutKeys("Mod", "Shift", "X")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<StrikethroughIcon />
|
<StrikethroughIcon />
|
||||||
</MarkToolbarButton>
|
</MarkToolbarButton>
|
||||||
|
|
||||||
<MarkToolbarButton nodeType={KEYS.code} tooltip={`Code ${shortcut("Mod", "E")}`}>
|
<MarkToolbarButton
|
||||||
|
nodeType={KEYS.code}
|
||||||
|
tooltip={<TooltipWithShortcut label="Code" keys={shortcutKeys("Mod", "E")} />}
|
||||||
|
>
|
||||||
<Code2Icon />
|
<Code2Icon />
|
||||||
</MarkToolbarButton>
|
</MarkToolbarButton>
|
||||||
|
|
||||||
<MarkToolbarButton
|
<MarkToolbarButton
|
||||||
nodeType={KEYS.highlight}
|
nodeType={KEYS.highlight}
|
||||||
tooltip={`Highlight ${shortcut("Mod", "Shift", "H")}`}
|
tooltip={
|
||||||
|
<TooltipWithShortcut label="Highlight" keys={shortcutKeys("Mod", "Shift", "H")} />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<HighlighterIcon />
|
<HighlighterIcon />
|
||||||
</MarkToolbarButton>
|
</MarkToolbarButton>
|
||||||
|
|
@ -113,7 +140,13 @@ export function FixedToolbarButtons() {
|
||||||
{!readOnly && onSave && hasUnsavedChanges && (
|
{!readOnly && onSave && hasUnsavedChanges && (
|
||||||
<ToolbarGroup>
|
<ToolbarGroup>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
tooltip={isSaving ? "Saving..." : `Save ${shortcut("Mod", "S")}`}
|
tooltip={
|
||||||
|
isSaving ? (
|
||||||
|
"Saving..."
|
||||||
|
) : (
|
||||||
|
<TooltipWithShortcut label="Save" keys={shortcutKeys("Mod", "S")} />
|
||||||
|
)
|
||||||
|
}
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ function PopoverContent({
|
||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
|
"bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border dark:border-neutral-700 p-4 shadow-md outline-hidden",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ function SelectContent({
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
|
"bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border dark:border-neutral-700 shadow-md",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className
|
||||||
|
|
@ -88,18 +88,28 @@ function SelectLabel({ className, ...props }: React.ComponentProps<typeof Select
|
||||||
function SelectItem({
|
function SelectItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
description,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Item> & {
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent/50 focus:text-accent-foreground hover:bg-accent/50 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm outline-hidden select-none transition-all data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 data-[highlighted]:bg-accent/50",
|
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 hover:bg-accent dark:hover:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm outline-hidden select-none transition-all data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 data-[highlighted]:bg-accent dark:data-[highlighted]:bg-neutral-700",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
{description ? (
|
||||||
|
<div className="flex flex-col py-0.5">
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
<span className="text-xs text-muted-foreground">{description}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
)}
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -111,7 +121,7 @@ function SelectSeparator({
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
data-slot="select-separator"
|
data-slot="select-separator"
|
||||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
className={cn("bg-border dark:bg-neutral-700 pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
23
surfsense_web/components/ui/shortcut-kbd.tsx
Normal file
23
surfsense_web/components/ui/shortcut-kbd.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ShortcutKbdProps {
|
||||||
|
keys: string[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShortcutKbd({ keys, className }: ShortcutKbdProps) {
|
||||||
|
if (keys.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn("ml-2 inline-flex items-center gap-0.5 text-white/50", className)}>
|
||||||
|
{keys.map((key) => (
|
||||||
|
<kbd
|
||||||
|
key={key}
|
||||||
|
className="inline-flex size-[16px] items-center justify-center rounded-[3px] bg-white/[0.08] font-sans text-[10px] leading-none"
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ function TooltipContent({
|
||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none",
|
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none select-none",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -92,11 +92,16 @@ export const surfsenseDocsDocumentWithChunks = surfsenseDocsDocument.extend({
|
||||||
/**
|
/**
|
||||||
* Get documents
|
* Get documents
|
||||||
*/
|
*/
|
||||||
|
export const documentSortByEnum = z.enum(["created_at", "title", "document_type"]);
|
||||||
|
export const sortOrderEnum = z.enum(["asc", "desc"]);
|
||||||
|
|
||||||
export const getDocumentsRequest = z.object({
|
export const getDocumentsRequest = z.object({
|
||||||
queryParams: paginationQueryParams
|
queryParams: paginationQueryParams
|
||||||
.extend({
|
.extend({
|
||||||
search_space_id: z.number().or(z.string()).optional(),
|
search_space_id: z.number().or(z.string()).optional(),
|
||||||
document_types: z.array(documentTypeEnum).optional(),
|
document_types: z.array(documentTypeEnum).optional(),
|
||||||
|
sort_by: documentSortByEnum.optional(),
|
||||||
|
sort_order: sortOrderEnum.optional(),
|
||||||
})
|
})
|
||||||
.nullish(),
|
.nullish(),
|
||||||
});
|
});
|
||||||
|
|
@ -311,6 +316,8 @@ export type UpdateDocumentResponse = z.infer<typeof updateDocumentResponse>;
|
||||||
export type DeleteDocumentRequest = z.infer<typeof deleteDocumentRequest>;
|
export type DeleteDocumentRequest = z.infer<typeof deleteDocumentRequest>;
|
||||||
export type DeleteDocumentResponse = z.infer<typeof deleteDocumentResponse>;
|
export type DeleteDocumentResponse = z.infer<typeof deleteDocumentResponse>;
|
||||||
export type DocumentTypeEnum = z.infer<typeof documentTypeEnum>;
|
export type DocumentTypeEnum = z.infer<typeof documentTypeEnum>;
|
||||||
|
export type DocumentSortBy = z.infer<typeof documentSortByEnum>;
|
||||||
|
export type SortOrder = z.infer<typeof sortOrderEnum>;
|
||||||
export type SurfsenseDocsChunk = z.infer<typeof surfsenseDocsChunk>;
|
export type SurfsenseDocsChunk = z.infer<typeof surfsenseDocsChunk>;
|
||||||
export type SurfsenseDocsDocument = z.infer<typeof surfsenseDocsDocument>;
|
export type SurfsenseDocsDocument = z.infer<typeof surfsenseDocsDocument>;
|
||||||
export type SurfsenseDocsDocumentWithChunks = z.infer<typeof surfsenseDocsDocumentWithChunks>;
|
export type SurfsenseDocsDocumentWithChunks = z.infer<typeof surfsenseDocsDocumentWithChunks>;
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,12 @@ export const pageLimitExceededInboxItem = inboxItem.extend({
|
||||||
// API Request/Response Schemas
|
// API Request/Response Schemas
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification category for tab-level filtering
|
||||||
|
*/
|
||||||
|
export const notificationCategory = z.enum(["comments", "status"]);
|
||||||
|
export type NotificationCategory = z.infer<typeof notificationCategory>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request schema for getting notifications
|
* Request schema for getting notifications
|
||||||
*/
|
*/
|
||||||
|
|
@ -204,6 +210,9 @@ export const getNotificationsRequest = z.object({
|
||||||
queryParams: z.object({
|
queryParams: z.object({
|
||||||
search_space_id: z.number().optional(),
|
search_space_id: z.number().optional(),
|
||||||
type: inboxItemTypeEnum.optional(),
|
type: inboxItemTypeEnum.optional(),
|
||||||
|
category: notificationCategory.optional(),
|
||||||
|
source_type: z.string().optional(),
|
||||||
|
filter: z.enum(["unread", "errors"]).optional(),
|
||||||
before_date: z.string().optional(),
|
before_date: z.string().optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
limit: z.number().min(1).max(100).optional(),
|
limit: z.number().min(1).max(100).optional(),
|
||||||
|
|
@ -261,6 +270,20 @@ export const getUnreadCountResponse = z.object({
|
||||||
recent_unread: z.number(), // Within SYNC_WINDOW_DAYS (14 days)
|
recent_unread: z.number(), // Within SYNC_WINDOW_DAYS (14 days)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response schema for notification source types (status tab filter)
|
||||||
|
*/
|
||||||
|
export const sourceTypeItem = z.object({
|
||||||
|
key: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
category: z.enum(["connector", "document"]),
|
||||||
|
count: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getSourceTypesResponse = z.object({
|
||||||
|
sources: z.array(sourceTypeItem),
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Type Guards for Metadata
|
// Type Guards for Metadata
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -387,3 +410,5 @@ export type MarkNotificationReadResponse = z.infer<typeof markNotificationReadRe
|
||||||
export type MarkAllNotificationsReadResponse = z.infer<typeof markAllNotificationsReadResponse>;
|
export type MarkAllNotificationsReadResponse = z.infer<typeof markAllNotificationsReadResponse>;
|
||||||
export type GetUnreadCountRequest = z.infer<typeof getUnreadCountRequest>;
|
export type GetUnreadCountRequest = z.infer<typeof getUnreadCountRequest>;
|
||||||
export type GetUnreadCountResponse = z.infer<typeof getUnreadCountResponse>;
|
export type GetUnreadCountResponse = z.infer<typeof getUnreadCountResponse>;
|
||||||
|
export type SourceTypeItem = z.infer<typeof sourceTypeItem>;
|
||||||
|
export type GetSourceTypesResponse = z.infer<typeof getSourceTypesResponse>;
|
||||||
|
|
|
||||||
|
|
@ -13,88 +13,74 @@ const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const replacements = [
|
const replacements = [
|
||||||
[
|
[
|
||||||
"__NEXT_PUBLIC_FASTAPI_BACKEND_URL__",
|
"__NEXT_PUBLIC_FASTAPI_BACKEND_URL__",
|
||||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000",
|
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000",
|
||||||
],
|
],
|
||||||
[
|
["__NEXT_PUBLIC_ELECTRIC_URL__", process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133"],
|
||||||
"__NEXT_PUBLIC_ELECTRIC_URL__",
|
[
|
||||||
process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133",
|
"__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__",
|
||||||
],
|
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "LOCAL",
|
||||||
[
|
],
|
||||||
"__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__",
|
["__NEXT_PUBLIC_ETL_SERVICE__", process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING"],
|
||||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "LOCAL",
|
["__NEXT_PUBLIC_DEPLOYMENT_MODE__", process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted"],
|
||||||
],
|
["__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__", process.env.NEXT_PUBLIC_ELECTRIC_AUTH_MODE || "insecure"],
|
||||||
[
|
|
||||||
"__NEXT_PUBLIC_ETL_SERVICE__",
|
|
||||||
process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"__NEXT_PUBLIC_DEPLOYMENT_MODE__",
|
|
||||||
process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__",
|
|
||||||
process.env.NEXT_PUBLIC_ELECTRIC_AUTH_MODE || "insecure",
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let filesProcessed = 0;
|
let filesProcessed = 0;
|
||||||
let filesModified = 0;
|
let filesModified = 0;
|
||||||
|
|
||||||
function walk(dir) {
|
function walk(dir) {
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const full = path.join(dir, entry.name);
|
const full = path.join(dir, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
walk(full);
|
walk(full);
|
||||||
} else if (entry.name.endsWith(".js")) {
|
} else if (entry.name.endsWith(".js")) {
|
||||||
filesProcessed++;
|
filesProcessed++;
|
||||||
let content = fs.readFileSync(full, "utf8");
|
let content = fs.readFileSync(full, "utf8");
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const [placeholder, value] of replacements) {
|
for (const [placeholder, value] of replacements) {
|
||||||
if (content.includes(placeholder)) {
|
if (content.includes(placeholder)) {
|
||||||
content = content.replaceAll(placeholder, value);
|
content = content.replaceAll(placeholder, value);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changed) {
|
if (changed) {
|
||||||
fs.writeFileSync(full, content);
|
fs.writeFileSync(full, content);
|
||||||
filesModified++;
|
filesModified++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[entrypoint] Replacing environment variable placeholders...");
|
console.log("[entrypoint] Replacing environment variable placeholders...");
|
||||||
for (const [placeholder, value] of replacements) {
|
for (const [placeholder, value] of replacements) {
|
||||||
console.log(` ${placeholder} -> ${value}`);
|
console.log(` ${placeholder} -> ${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
walk(path.join(__dirname, ".next"));
|
walk(path.join(__dirname, ".next"));
|
||||||
|
|
||||||
const serverJs = path.join(__dirname, "server.js");
|
const serverJs = path.join(__dirname, "server.js");
|
||||||
if (fs.existsSync(serverJs)) {
|
if (fs.existsSync(serverJs)) {
|
||||||
let content = fs.readFileSync(serverJs, "utf8");
|
let content = fs.readFileSync(serverJs, "utf8");
|
||||||
let changed = false;
|
let changed = false;
|
||||||
filesProcessed++;
|
filesProcessed++;
|
||||||
for (const [placeholder, value] of replacements) {
|
for (const [placeholder, value] of replacements) {
|
||||||
if (content.includes(placeholder)) {
|
if (content.includes(placeholder)) {
|
||||||
content = content.replaceAll(placeholder, value);
|
content = content.replaceAll(placeholder, value);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changed) {
|
if (changed) {
|
||||||
fs.writeFileSync(serverJs, content);
|
fs.writeFileSync(serverJs, content);
|
||||||
filesModified++;
|
filesModified++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(`[entrypoint] Done. Scanned ${filesProcessed} files, modified ${filesModified}.`);
|
||||||
`[entrypoint] Done. Scanned ${filesProcessed} files, modified ${filesModified}.`
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,9 @@ export function useComments({ messageId, enabled = true }: UseCommentsOptions) {
|
||||||
|
|
||||||
if (_batchInflight && _batchTargetIds.has(messageId)) {
|
if (_batchInflight && _batchTargetIds.has(messageId)) {
|
||||||
await _batchInflight;
|
await _batchInflight;
|
||||||
const cached = queryClient.getQueryData<GetCommentsResponse>(cacheKeys.comments.byMessage(messageId));
|
const cached = queryClient.getQueryData<GetCommentsResponse>(
|
||||||
|
cacheKeys.comments.byMessage(messageId)
|
||||||
|
);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
127
surfsense_web/hooks/use-document-search.ts
Normal file
127
surfsense_web/hooks/use-document-search.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||||
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
|
import { type DocumentDisplay, toDisplayDoc } from "./use-documents";
|
||||||
|
|
||||||
|
const SEARCH_INITIAL_SIZE = 20;
|
||||||
|
const SEARCH_SCROLL_SIZE = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated document search hook.
|
||||||
|
*
|
||||||
|
* Handles title-based search with server-side filtering,
|
||||||
|
* pagination via skip/page_size, and staleness detection
|
||||||
|
* so fast typing never renders stale results.
|
||||||
|
*
|
||||||
|
* @param searchSpaceId - The search space to search within
|
||||||
|
* @param query - The debounced search query
|
||||||
|
* @param activeTypes - Document types to filter by
|
||||||
|
* @param enabled - When false the hook resets and stops fetching
|
||||||
|
*/
|
||||||
|
export function useDocumentSearch(
|
||||||
|
searchSpaceId: number,
|
||||||
|
query: string,
|
||||||
|
activeTypes: DocumentTypeEnum[],
|
||||||
|
enabled: boolean
|
||||||
|
) {
|
||||||
|
const [documents, setDocuments] = useState<DocumentDisplay[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
const apiLoadedRef = useRef(0);
|
||||||
|
const queryRef = useRef(query);
|
||||||
|
|
||||||
|
const isActive = enabled && !!query.trim();
|
||||||
|
const activeTypesKey = activeTypes.join(",");
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTypesKey serializes activeTypes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive || !searchSpaceId) {
|
||||||
|
setDocuments([]);
|
||||||
|
setHasMore(false);
|
||||||
|
setError(false);
|
||||||
|
apiLoadedRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
queryRef.current = query;
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
|
||||||
|
documentsApiService
|
||||||
|
.searchDocuments({
|
||||||
|
queryParams: {
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
page: 0,
|
||||||
|
page_size: SEARCH_INITIAL_SIZE,
|
||||||
|
title: query.trim(),
|
||||||
|
...(activeTypes.length > 0 && { document_types: activeTypes }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (cancelled || queryRef.current !== query) return;
|
||||||
|
setDocuments(response.items.map(toDisplayDoc));
|
||||||
|
setHasMore(response.has_more);
|
||||||
|
apiLoadedRef.current = response.items.length;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.error("[useDocumentSearch] Search failed:", err);
|
||||||
|
setError(true);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [query, searchSpaceId, isActive, activeTypesKey]);
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTypesKey serializes activeTypes
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (loadingMore || !isActive || !hasMore) return;
|
||||||
|
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const response = await documentsApiService.searchDocuments({
|
||||||
|
queryParams: {
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
skip: apiLoadedRef.current,
|
||||||
|
page_size: SEARCH_SCROLL_SIZE,
|
||||||
|
title: query.trim(),
|
||||||
|
...(activeTypes.length > 0 && { document_types: activeTypes }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (queryRef.current !== query) return;
|
||||||
|
|
||||||
|
setDocuments((prev) => [...prev, ...response.items.map(toDisplayDoc)]);
|
||||||
|
setHasMore(response.has_more);
|
||||||
|
apiLoadedRef.current += response.items.length;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[useDocumentSearch] Load more failed:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [loadingMore, isActive, hasMore, searchSpaceId, query, activeTypesKey]);
|
||||||
|
|
||||||
|
const removeItems = useCallback((ids: number[]) => {
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
setDocuments((prev) => prev.filter((item) => !idSet.has(item.id)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
documents,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
hasMore,
|
||||||
|
loadMore,
|
||||||
|
error,
|
||||||
|
removeItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
118
surfsense_web/hooks/use-documents-processing.ts
Normal file
118
surfsense_web/hooks/use-documents-processing.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useElectricClient } from "@/lib/electric/context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether any documents in the search space are currently being
|
||||||
|
* uploaded or indexed (status = "pending" | "processing").
|
||||||
|
*
|
||||||
|
* Covers both manual file uploads (2-phase pattern) and all connector indexers,
|
||||||
|
* since both create documents with status = pending before processing.
|
||||||
|
*
|
||||||
|
* The sync shape uses the same columns as useDocuments so Electric can share
|
||||||
|
* the subscription when both hooks are active simultaneously.
|
||||||
|
*/
|
||||||
|
export function useDocumentsProcessing(searchSpaceId: number | null): boolean {
|
||||||
|
const electricClient = useElectricClient();
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchSpaceId || !electricClient) return;
|
||||||
|
|
||||||
|
const spaceId = searchSpaceId;
|
||||||
|
const client = electricClient;
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
if (liveQueryRef.current) {
|
||||||
|
try {
|
||||||
|
liveQueryRef.current.unsubscribe?.();
|
||||||
|
} catch {
|
||||||
|
/* PGlite may be closed */
|
||||||
|
}
|
||||||
|
liveQueryRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handle = await client.syncShape({
|
||||||
|
table: "documents",
|
||||||
|
where: `search_space_id = ${spaceId}`,
|
||||||
|
columns: [
|
||||||
|
"id",
|
||||||
|
"document_type",
|
||||||
|
"search_space_id",
|
||||||
|
"title",
|
||||||
|
"created_by_id",
|
||||||
|
"created_at",
|
||||||
|
"status",
|
||||||
|
],
|
||||||
|
primaryKey: ["id"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (!handle.isUpToDate && handle.initialSyncPromise) {
|
||||||
|
await Promise.race([
|
||||||
|
handle.initialSyncPromise,
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 5000)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
const db = client.db as {
|
||||||
|
live?: {
|
||||||
|
query: <T>(
|
||||||
|
sql: string,
|
||||||
|
params?: (number | string)[]
|
||||||
|
) => Promise<{
|
||||||
|
subscribe: (cb: (result: { rows: T[] }) => void) => void;
|
||||||
|
unsubscribe?: () => void;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!db.live?.query) return;
|
||||||
|
|
||||||
|
const liveQuery = await db.live.query<{ count: number | string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM documents
|
||||||
|
WHERE search_space_id = $1
|
||||||
|
AND (status->>'state' = 'pending' OR status->>'state' = 'processing')`,
|
||||||
|
[spaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
liveQuery.unsubscribe?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => {
|
||||||
|
if (!mounted || !result.rows?.[0]) return;
|
||||||
|
setIsProcessing((Number(result.rows[0].count) || 0) > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
liveQueryRef.current = liveQuery;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[useDocumentsProcessing] Electric setup failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setup();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
if (liveQueryRef.current) {
|
||||||
|
try {
|
||||||
|
liveQueryRef.current.unsubscribe?.();
|
||||||
|
} catch {
|
||||||
|
/* PGlite may be closed */
|
||||||
|
}
|
||||||
|
liveQueryRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [searchSpaceId, electricClient]);
|
||||||
|
|
||||||
|
return isProcessing;
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import type { DocumentSortBy, DocumentTypeEnum, SortOrder } from "@/contracts/types/document.types";
|
||||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
|
import { filterNewElectricItems, getNewestTimestamp } from "@/lib/electric/baseline";
|
||||||
import type { SyncHandle } from "@/lib/electric/client";
|
import type { SyncHandle } from "@/lib/electric/client";
|
||||||
import { useElectricClient } from "@/lib/electric/context";
|
import { useElectricClient } from "@/lib/electric/context";
|
||||||
|
|
||||||
// Stable empty array to prevent infinite re-renders when no typeFilter is provided
|
|
||||||
const EMPTY_TYPE_FILTER: DocumentTypeEnum[] = [];
|
|
||||||
|
|
||||||
// Document status type (matches backend DocumentStatus JSONB)
|
|
||||||
export interface DocumentStatusType {
|
export interface DocumentStatusType {
|
||||||
state: "ready" | "pending" | "processing" | "failed";
|
state: "ready" | "pending" | "processing" | "failed";
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Document from Electric sync (lightweight table columns - NO content/metadata)
|
|
||||||
interface DocumentElectric {
|
interface DocumentElectric {
|
||||||
id: number;
|
id: number;
|
||||||
search_space_id: number;
|
search_space_id: number;
|
||||||
|
|
@ -27,7 +22,6 @@ interface DocumentElectric {
|
||||||
status: DocumentStatusType | null;
|
status: DocumentStatusType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Document for display (with resolved user name and email)
|
|
||||||
export interface DocumentDisplay {
|
export interface DocumentDisplay {
|
||||||
id: number;
|
id: number;
|
||||||
search_space_id: number;
|
search_space_id: number;
|
||||||
|
|
@ -40,87 +34,86 @@ export interface DocumentDisplay {
|
||||||
status: DocumentStatusType;
|
status: DocumentStatusType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface ApiDocumentInput {
|
||||||
* Deduplicate by ID and sort by created_at descending (newest first)
|
id: number;
|
||||||
*/
|
search_space_id: number;
|
||||||
function deduplicateAndSort<T extends { id: number; created_at: string }>(items: T[]): T[] {
|
document_type: string;
|
||||||
const seen = new Map<number, T>();
|
title: string;
|
||||||
for (const item of items) {
|
created_by_id?: string | null;
|
||||||
// Keep the most recent version if duplicate
|
created_by_name?: string | null;
|
||||||
const existing = seen.get(item.id);
|
created_by_email?: string | null;
|
||||||
if (!existing || new Date(item.created_at) > new Date(existing.created_at)) {
|
created_at: string;
|
||||||
seen.set(item.id, item);
|
status?: DocumentStatusType | null;
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(seen.values()).sort(
|
|
||||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function toDisplayDoc(item: ApiDocumentInput): DocumentDisplay {
|
||||||
* Check if a document has valid/complete data
|
return {
|
||||||
*/
|
id: item.id,
|
||||||
|
search_space_id: item.search_space_id,
|
||||||
|
document_type: item.document_type,
|
||||||
|
title: item.title,
|
||||||
|
created_by_id: item.created_by_id ?? null,
|
||||||
|
created_by_name: item.created_by_name ?? null,
|
||||||
|
created_by_email: item.created_by_email ?? null,
|
||||||
|
created_at: item.created_at,
|
||||||
|
status: item.status ?? { state: "ready" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_TYPE_FILTER: DocumentTypeEnum[] = [];
|
||||||
|
const INITIAL_PAGE_SIZE = 20;
|
||||||
|
const SCROLL_PAGE_SIZE = 5;
|
||||||
|
|
||||||
function isValidDocument(doc: DocumentElectric): boolean {
|
function isValidDocument(doc: DocumentElectric): boolean {
|
||||||
return doc.id != null && doc.title != null && doc.title !== "";
|
return doc.id != null && doc.title != null && doc.title !== "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real-time documents hook with Electric SQL
|
* Paginated documents hook with Electric SQL real-time updates.
|
||||||
*
|
*
|
||||||
* Architecture (100% Reliable):
|
* Architecture:
|
||||||
* 1. API is the PRIMARY source of truth - always loads first
|
* 1. API is the PRIMARY data source — fetches pages on demand
|
||||||
* 2. Electric provides REAL-TIME updates for additions and deletions
|
* 2. Type counts come from a dedicated lightweight API endpoint
|
||||||
* 3. Use syncHandle.isUpToDate to determine if deletions can be trusted
|
* 3. Electric provides REAL-TIME updates (new docs, deletions, status changes)
|
||||||
* 4. Handles bulk deletions correctly by checking sync state
|
* 4. Server-side sorting via sort_by + sort_order params
|
||||||
*
|
*
|
||||||
* Filtering strategy:
|
* @param searchSpaceId - The search space to load documents for
|
||||||
* - Internal state always stores ALL documents (unfiltered)
|
* @param typeFilter - Document types to filter by (server-side)
|
||||||
* - typeFilter is applied client-side when returning documents
|
* @param sortBy - Column to sort by (server-side)
|
||||||
* - typeCounts always reflect the full dataset so the filter sidebar stays complete
|
* @param sortOrder - Sort direction (server-side)
|
||||||
* - Changing filters is instant (no API re-fetch or Electric re-sync)
|
|
||||||
*
|
|
||||||
* @param searchSpaceId - The search space ID to filter documents
|
|
||||||
* @param typeFilter - Optional document types to filter by (applied client-side)
|
|
||||||
*/
|
*/
|
||||||
export function useDocuments(
|
export function useDocuments(
|
||||||
searchSpaceId: number | null,
|
searchSpaceId: number | null,
|
||||||
typeFilter: DocumentTypeEnum[] = EMPTY_TYPE_FILTER
|
typeFilter: DocumentTypeEnum[] = EMPTY_TYPE_FILTER,
|
||||||
|
sortBy: DocumentSortBy = "created_at",
|
||||||
|
sortOrder: SortOrder = "desc"
|
||||||
) {
|
) {
|
||||||
const electricClient = useElectricClient();
|
const electricClient = useElectricClient();
|
||||||
|
|
||||||
// Internal state: ALL documents (unfiltered)
|
const [documents, setDocuments] = useState<DocumentDisplay[]>([]);
|
||||||
const [allDocuments, setAllDocuments] = useState<DocumentDisplay[]>([]);
|
const [typeCounts, setTypeCounts] = useState<Record<string, number>>({});
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
// Track if initial API load is complete (source of truth)
|
const apiLoadedCountRef = useRef(0);
|
||||||
const apiLoadedRef = useRef(false);
|
const initialLoadDoneRef = useRef(false);
|
||||||
|
const prevParamsRef = useRef<{ sortBy: string; sortOrder: string; typeFilterKey: string } | null>(
|
||||||
// User cache: userId → displayName / email
|
null
|
||||||
|
);
|
||||||
|
// Snapshot of all doc IDs from Electric's first callback after initial load.
|
||||||
|
// Anything appearing in subsequent callbacks NOT in this set is genuinely new.
|
||||||
|
const electricBaselineIdsRef = useRef<Set<number> | null>(null);
|
||||||
|
const newestApiTimestampRef = useRef<string | null>(null);
|
||||||
const userCacheRef = useRef<Map<string, string>>(new Map());
|
const userCacheRef = useRef<Map<string, string>>(new Map());
|
||||||
const emailCacheRef = useRef<Map<string, string>>(new Map());
|
const emailCacheRef = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
// Electric sync refs
|
|
||||||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
const syncHandleRef = useRef<SyncHandle | null>(null);
|
||||||
const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
|
const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
|
||||||
|
|
||||||
// Type counts from ALL documents (unfiltered) — keeps filter sidebar complete
|
const typeFilterKey = typeFilter.join(",");
|
||||||
const typeCounts = useMemo(() => {
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const doc of allDocuments) {
|
|
||||||
counts[doc.document_type] = (counts[doc.document_type] || 0) + 1;
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}, [allDocuments]);
|
|
||||||
|
|
||||||
// Client-side filtered documents for display
|
|
||||||
const documents = useMemo(() => {
|
|
||||||
if (typeFilter.length === 0) return allDocuments;
|
|
||||||
const filterSet = new Set<string>(typeFilter);
|
|
||||||
return allDocuments.filter((doc) => filterSet.has(doc.document_type));
|
|
||||||
}, [allDocuments, typeFilter]);
|
|
||||||
|
|
||||||
// Populate user cache from API response
|
|
||||||
const populateUserCache = useCallback(
|
const populateUserCache = useCallback(
|
||||||
(
|
(
|
||||||
items: Array<{
|
items: Array<{
|
||||||
|
|
@ -143,33 +136,11 @@ export function useDocuments(
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert API item to display doc
|
|
||||||
const apiToDisplayDoc = useCallback(
|
const apiToDisplayDoc = useCallback(
|
||||||
(item: {
|
(item: ApiDocumentInput): DocumentDisplay => toDisplayDoc(item),
|
||||||
id: number;
|
|
||||||
search_space_id: number;
|
|
||||||
document_type: string;
|
|
||||||
title: string;
|
|
||||||
created_by_id?: string | null;
|
|
||||||
created_by_name?: string | null;
|
|
||||||
created_by_email?: string | null;
|
|
||||||
created_at: string;
|
|
||||||
status?: DocumentStatusType | null;
|
|
||||||
}): DocumentDisplay => ({
|
|
||||||
id: item.id,
|
|
||||||
search_space_id: item.search_space_id,
|
|
||||||
document_type: item.document_type,
|
|
||||||
title: item.title,
|
|
||||||
created_by_id: item.created_by_id ?? null,
|
|
||||||
created_by_name: item.created_by_name ?? null,
|
|
||||||
created_by_email: item.created_by_email ?? null,
|
|
||||||
created_at: item.created_at,
|
|
||||||
status: item.status ?? { state: "ready" },
|
|
||||||
}),
|
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert Electric doc to display doc
|
|
||||||
const electricToDisplayDoc = useCallback(
|
const electricToDisplayDoc = useCallback(
|
||||||
(doc: DocumentElectric): DocumentDisplay => ({
|
(doc: DocumentElectric): DocumentDisplay => ({
|
||||||
...doc,
|
...doc,
|
||||||
|
|
@ -184,66 +155,91 @@ export function useDocuments(
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// STEP 1: Load ALL documents from API (PRIMARY source of truth).
|
// EFFECT 1: Fetch first page + type counts when params change
|
||||||
// Uses React Query for automatic deduplication, caching, and staleTime so
|
// biome-ignore lint/correctness/useExhaustiveDependencies: typeFilterKey serializes typeFilter
|
||||||
// multiple components mounting useDocuments(sameId) share a single request.
|
|
||||||
const {
|
|
||||||
data: apiResponse,
|
|
||||||
isLoading: apiLoading,
|
|
||||||
error: apiError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["documents", "all", searchSpaceId],
|
|
||||||
queryFn: () =>
|
|
||||||
documentsApiService.getDocuments({
|
|
||||||
queryParams: {
|
|
||||||
search_space_id: searchSpaceId!,
|
|
||||||
page: 0,
|
|
||||||
page_size: -1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
enabled: !!searchSpaceId,
|
|
||||||
staleTime: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Seed local state from API response (runs once per fresh fetch)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!apiResponse) return;
|
if (!searchSpaceId) return;
|
||||||
populateUserCache(apiResponse.items);
|
|
||||||
const docs = apiResponse.items.map(apiToDisplayDoc);
|
|
||||||
setAllDocuments(docs);
|
|
||||||
apiLoadedRef.current = true;
|
|
||||||
setError(null);
|
|
||||||
}, [apiResponse, populateUserCache, apiToDisplayDoc]);
|
|
||||||
|
|
||||||
// Propagate loading / error from React Query
|
let cancelled = false;
|
||||||
useEffect(() => {
|
|
||||||
setLoading(apiLoading);
|
|
||||||
}, [apiLoading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const prev = prevParamsRef.current;
|
||||||
if (apiError) {
|
const isSortOnlyChange =
|
||||||
setError(apiError instanceof Error ? apiError : new Error("Failed to load documents"));
|
initialLoadDoneRef.current &&
|
||||||
|
prev !== null &&
|
||||||
|
prev.typeFilterKey === typeFilterKey &&
|
||||||
|
(prev.sortBy !== sortBy || prev.sortOrder !== sortOrder);
|
||||||
|
prevParamsRef.current = { sortBy, sortOrder, typeFilterKey };
|
||||||
|
|
||||||
|
if (!isSortOnlyChange) {
|
||||||
|
setLoading(true);
|
||||||
|
setDocuments([]);
|
||||||
|
setTotal(0);
|
||||||
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
}, [apiError]);
|
apiLoadedCountRef.current = 0;
|
||||||
|
initialLoadDoneRef.current = false;
|
||||||
|
electricBaselineIdsRef.current = null;
|
||||||
|
newestApiTimestampRef.current = null;
|
||||||
|
|
||||||
// EFFECT 2: Start Electric sync + live query for real-time updates
|
const fetchInitialData = async () => {
|
||||||
// No type filter — syncs and queries ALL documents; filtering is client-side
|
try {
|
||||||
|
const [docsResponse, countsResponse] = await Promise.all([
|
||||||
|
documentsApiService.getDocuments({
|
||||||
|
queryParams: {
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
page: 0,
|
||||||
|
page_size: INITIAL_PAGE_SIZE,
|
||||||
|
...(typeFilter.length > 0 && { document_types: typeFilter }),
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
documentsApiService.getDocumentTypeCounts({
|
||||||
|
queryParams: { search_space_id: searchSpaceId },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
populateUserCache(docsResponse.items);
|
||||||
|
const docs = docsResponse.items.map(apiToDisplayDoc);
|
||||||
|
setDocuments(docs);
|
||||||
|
setTotal(docsResponse.total);
|
||||||
|
setHasMore(docsResponse.has_more);
|
||||||
|
setTypeCounts(countsResponse);
|
||||||
|
setError(null);
|
||||||
|
apiLoadedCountRef.current = docsResponse.items.length;
|
||||||
|
newestApiTimestampRef.current = getNewestTimestamp(docsResponse.items);
|
||||||
|
initialLoadDoneRef.current = true;
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.error("[useDocuments] Initial load failed:", err);
|
||||||
|
setError(err instanceof Error ? err : new Error("Failed to load documents"));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchInitialData();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [searchSpaceId, typeFilterKey, sortBy, sortOrder, populateUserCache, apiToDisplayDoc]);
|
||||||
|
|
||||||
|
// EFFECT 2: Electric sync + live query for real-time updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchSpaceId || !electricClient) return;
|
if (!searchSpaceId || !electricClient) return;
|
||||||
|
|
||||||
// Capture validated values for async closure
|
|
||||||
const spaceId = searchSpaceId;
|
const spaceId = searchSpaceId;
|
||||||
const client = electricClient;
|
const client = electricClient;
|
||||||
|
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
async function setupElectricRealtime() {
|
async function setupElectricRealtime() {
|
||||||
// Cleanup previous subscriptions
|
|
||||||
if (syncHandleRef.current) {
|
if (syncHandleRef.current) {
|
||||||
try {
|
try {
|
||||||
syncHandleRef.current.unsubscribe();
|
syncHandleRef.current.unsubscribe();
|
||||||
} catch {
|
} catch {
|
||||||
// PGlite may already be closed during cleanup
|
/* PGlite may already be closed */
|
||||||
}
|
}
|
||||||
syncHandleRef.current = null;
|
syncHandleRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
@ -251,15 +247,12 @@ export function useDocuments(
|
||||||
try {
|
try {
|
||||||
liveQueryRef.current.unsubscribe?.();
|
liveQueryRef.current.unsubscribe?.();
|
||||||
} catch {
|
} catch {
|
||||||
// PGlite may already be closed during cleanup
|
/* PGlite may already be closed */
|
||||||
}
|
}
|
||||||
liveQueryRef.current = null;
|
liveQueryRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[useDocuments] Starting Electric sync for real-time updates");
|
|
||||||
|
|
||||||
// Start Electric sync (all documents for this search space)
|
|
||||||
const handle = await client.syncShape({
|
const handle = await client.syncShape({
|
||||||
table: "documents",
|
table: "documents",
|
||||||
where: `search_space_id = ${spaceId}`,
|
where: `search_space_id = ${spaceId}`,
|
||||||
|
|
@ -281,20 +274,16 @@ export function useDocuments(
|
||||||
}
|
}
|
||||||
|
|
||||||
syncHandleRef.current = handle;
|
syncHandleRef.current = handle;
|
||||||
console.log("[useDocuments] Sync started, isUpToDate:", handle.isUpToDate);
|
|
||||||
|
|
||||||
// Wait for initial sync (with timeout)
|
|
||||||
if (!handle.isUpToDate && handle.initialSyncPromise) {
|
if (!handle.isUpToDate && handle.initialSyncPromise) {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
handle.initialSyncPromise,
|
handle.initialSyncPromise,
|
||||||
new Promise((resolve) => setTimeout(resolve, 5000)),
|
new Promise((resolve) => setTimeout(resolve, 5000)),
|
||||||
]);
|
]);
|
||||||
console.log("[useDocuments] Initial sync complete, isUpToDate:", handle.isUpToDate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Set up live query (unfiltered — type filtering is done client-side)
|
|
||||||
const db = client.db as {
|
const db = client.db as {
|
||||||
live?: {
|
live?: {
|
||||||
query: <T>(
|
query: <T>(
|
||||||
|
|
@ -307,13 +296,10 @@ export function useDocuments(
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!db.live?.query) {
|
if (!db.live?.query) return;
|
||||||
console.warn("[useDocuments] Live queries not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = `SELECT id, document_type, search_space_id, title, created_by_id, created_at, status
|
const query = `SELECT id, document_type, search_space_id, title, created_by_id, created_at, status
|
||||||
FROM documents
|
FROM documents
|
||||||
WHERE search_space_id = $1
|
WHERE search_space_id = $1
|
||||||
ORDER BY created_at DESC`;
|
ORDER BY created_at DESC`;
|
||||||
|
|
||||||
|
|
@ -324,22 +310,12 @@ export function useDocuments(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[useDocuments] Live query subscribed");
|
|
||||||
|
|
||||||
liveQuery.subscribe((result: { rows: DocumentElectric[] }) => {
|
liveQuery.subscribe((result: { rows: DocumentElectric[] }) => {
|
||||||
if (!mounted || !result.rows) return;
|
if (!mounted || !result.rows || !initialLoadDoneRef.current) return;
|
||||||
|
|
||||||
// DEBUG: Log first few raw documents to see what's coming from Electric
|
|
||||||
console.log("[useDocuments] Raw data sample:", result.rows.slice(0, 3));
|
|
||||||
|
|
||||||
const validItems = result.rows.filter(isValidDocument);
|
const validItems = result.rows.filter(isValidDocument);
|
||||||
const isFullySynced = syncHandleRef.current?.isUpToDate ?? false;
|
const isFullySynced = syncHandleRef.current?.isUpToDate ?? false;
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[useDocuments] Live update: ${result.rows.length} raw, ${validItems.length} valid, synced: ${isFullySynced}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch user names for new users (non-blocking)
|
|
||||||
const unknownUserIds = validItems
|
const unknownUserIds = validItems
|
||||||
.filter(
|
.filter(
|
||||||
(doc): doc is DocumentElectric & { created_by_id: string } =>
|
(doc): doc is DocumentElectric & { created_by_id: string } =>
|
||||||
|
|
@ -350,12 +326,16 @@ export function useDocuments(
|
||||||
if (unknownUserIds.length > 0) {
|
if (unknownUserIds.length > 0) {
|
||||||
documentsApiService
|
documentsApiService
|
||||||
.getDocuments({
|
.getDocuments({
|
||||||
queryParams: { search_space_id: spaceId, page: 0, page_size: 20 },
|
queryParams: {
|
||||||
|
search_space_id: spaceId,
|
||||||
|
page: 0,
|
||||||
|
page_size: 20,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
populateUserCache(response.items);
|
populateUserCache(response.items);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setAllDocuments((prev) =>
|
setDocuments((prev) =>
|
||||||
prev.map((doc) => ({
|
prev.map((doc) => ({
|
||||||
...doc,
|
...doc,
|
||||||
created_by_name: doc.created_by_id
|
created_by_name: doc.created_by_id
|
||||||
|
|
@ -371,46 +351,20 @@ export function useDocuments(
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Smart update logic based on sync state
|
setDocuments((prev) => {
|
||||||
setAllDocuments((prev) => {
|
|
||||||
// Don't process if API hasn't loaded yet
|
|
||||||
if (!apiLoadedRef.current) {
|
|
||||||
console.log("[useDocuments] Waiting for API load, skipping live update");
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 1: Live query is empty
|
|
||||||
if (validItems.length === 0) {
|
|
||||||
if (isFullySynced && prev.length > 0) {
|
|
||||||
// Electric is fully synced and says 0 items - trust it (all deleted)
|
|
||||||
console.log("[useDocuments] All documents deleted (Electric synced)");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
// Partial sync or error - keep existing
|
|
||||||
console.log("[useDocuments] Empty live result, keeping existing");
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: Electric is fully synced - TRUST IT COMPLETELY (handles bulk deletes)
|
|
||||||
if (isFullySynced) {
|
|
||||||
const liveDocs = deduplicateAndSort(validItems.map(electricToDisplayDoc));
|
|
||||||
console.log(
|
|
||||||
`[useDocuments] Synced update: ${liveDocs.length} docs (was ${prev.length})`
|
|
||||||
);
|
|
||||||
return liveDocs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 3: Partial sync - only ADD new items, don't remove any
|
|
||||||
const existingIds = new Set(prev.map((d) => d.id));
|
|
||||||
const liveIds = new Set(validItems.map((d) => d.id));
|
const liveIds = new Set(validItems.map((d) => d.id));
|
||||||
|
const prevIds = new Set(prev.map((d) => d.id));
|
||||||
|
|
||||||
// Find new items (in live but not in prev)
|
const newItems = filterNewElectricItems(
|
||||||
const newItems = validItems
|
validItems,
|
||||||
.filter((item) => !existingIds.has(item.id))
|
liveIds,
|
||||||
.map(electricToDisplayDoc);
|
prevIds,
|
||||||
|
electricBaselineIdsRef,
|
||||||
|
newestApiTimestampRef.current
|
||||||
|
).map(electricToDisplayDoc);
|
||||||
|
|
||||||
// Find updated items (in both, update with latest data)
|
// Update existing docs (status changes, title edits)
|
||||||
const updatedPrev = prev.map((doc) => {
|
let updated = prev.map((doc) => {
|
||||||
if (liveIds.has(doc.id)) {
|
if (liveIds.has(doc.id)) {
|
||||||
const liveItem = validItems.find((v) => v.id === doc.id);
|
const liveItem = validItems.find((v) => v.id === doc.id);
|
||||||
if (liveItem) {
|
if (liveItem) {
|
||||||
|
|
@ -420,19 +374,32 @@ export function useDocuments(
|
||||||
return doc;
|
return doc;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (newItems.length > 0) {
|
// Remove deleted docs (only when fully synced)
|
||||||
console.log(`[useDocuments] Adding ${newItems.length} new items (partial sync)`);
|
if (isFullySynced) {
|
||||||
return deduplicateAndSort([...newItems, ...updatedPrev]);
|
updated = updated.filter((doc) => liveIds.has(doc.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedPrev;
|
if (newItems.length > 0) {
|
||||||
|
return [...newItems, ...updated];
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update type counts when Electric detects changes
|
||||||
|
if (isFullySynced && validItems.length > 0) {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const item of validItems) {
|
||||||
|
counts[item.document_type] = (counts[item.document_type] || 0) + 1;
|
||||||
|
}
|
||||||
|
setTypeCounts(counts);
|
||||||
|
setTotal(validItems.length);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
liveQueryRef.current = liveQuery;
|
liveQueryRef.current = liveQuery;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[useDocuments] Electric setup failed:", err);
|
console.error("[useDocuments] Electric setup failed:", err);
|
||||||
// Don't set error - API data is already loaded
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,7 +411,7 @@ export function useDocuments(
|
||||||
try {
|
try {
|
||||||
syncHandleRef.current.unsubscribe();
|
syncHandleRef.current.unsubscribe();
|
||||||
} catch {
|
} catch {
|
||||||
// PGlite may already be closed during cleanup
|
/* PGlite may already be closed */
|
||||||
}
|
}
|
||||||
syncHandleRef.current = null;
|
syncHandleRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
@ -452,32 +419,85 @@ export function useDocuments(
|
||||||
try {
|
try {
|
||||||
liveQueryRef.current.unsubscribe?.();
|
liveQueryRef.current.unsubscribe?.();
|
||||||
} catch {
|
} catch {
|
||||||
// PGlite may already be closed during cleanup
|
/* PGlite may already be closed */
|
||||||
}
|
}
|
||||||
liveQueryRef.current = null;
|
liveQueryRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [searchSpaceId, electricClient, electricToDisplayDoc, populateUserCache]);
|
}, [searchSpaceId, electricClient, electricToDisplayDoc, populateUserCache]);
|
||||||
|
|
||||||
// Track previous searchSpaceId to detect actual changes
|
// Reset on search space change
|
||||||
const prevSearchSpaceIdRef = useRef<number | null>(null);
|
const prevSearchSpaceIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Reset on search space change (not on initial mount)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevSearchSpaceIdRef.current !== null && prevSearchSpaceIdRef.current !== searchSpaceId) {
|
if (prevSearchSpaceIdRef.current !== null && prevSearchSpaceIdRef.current !== searchSpaceId) {
|
||||||
setAllDocuments([]);
|
setDocuments([]);
|
||||||
apiLoadedRef.current = false;
|
setTypeCounts({});
|
||||||
|
setTotal(0);
|
||||||
|
setHasMore(false);
|
||||||
|
apiLoadedCountRef.current = 0;
|
||||||
|
initialLoadDoneRef.current = false;
|
||||||
|
electricBaselineIdsRef.current = null;
|
||||||
|
newestApiTimestampRef.current = null;
|
||||||
userCacheRef.current.clear();
|
userCacheRef.current.clear();
|
||||||
emailCacheRef.current.clear();
|
emailCacheRef.current.clear();
|
||||||
}
|
}
|
||||||
prevSearchSpaceIdRef.current = searchSpaceId;
|
prevSearchSpaceIdRef.current = searchSpaceId;
|
||||||
}, [searchSpaceId]);
|
}, [searchSpaceId]);
|
||||||
|
|
||||||
|
// Load more pages via API
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: typeFilterKey serializes typeFilter
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (loadingMore || !hasMore || !searchSpaceId) return;
|
||||||
|
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const response = await documentsApiService.getDocuments({
|
||||||
|
queryParams: {
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
skip: apiLoadedCountRef.current,
|
||||||
|
page_size: SCROLL_PAGE_SIZE,
|
||||||
|
...(typeFilter.length > 0 && { document_types: typeFilter }),
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
populateUserCache(response.items);
|
||||||
|
const newDocs = response.items.map(apiToDisplayDoc);
|
||||||
|
|
||||||
|
setDocuments((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((d) => d.id));
|
||||||
|
const deduped = newDocs.filter((d) => !existingIds.has(d.id));
|
||||||
|
return [...prev, ...deduped];
|
||||||
|
});
|
||||||
|
setTotal(response.total);
|
||||||
|
setHasMore(response.has_more);
|
||||||
|
apiLoadedCountRef.current += response.items.length;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[useDocuments] Load more failed:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
loadingMore,
|
||||||
|
hasMore,
|
||||||
|
searchSpaceId,
|
||||||
|
typeFilterKey,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
populateUserCache,
|
||||||
|
apiToDisplayDoc,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents,
|
documents,
|
||||||
typeCounts,
|
typeCounts,
|
||||||
total: documents.length,
|
total,
|
||||||
loading,
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
hasMore,
|
||||||
|
loadMore,
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,497 +1,402 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
|
import type { InboxItem, NotificationCategory } from "@/contracts/types/inbox.types";
|
||||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||||
import type { SyncHandle } from "@/lib/electric/client";
|
import { filterNewElectricItems, getNewestTimestamp } from "@/lib/electric/baseline";
|
||||||
import { useElectricClient } from "@/lib/electric/context";
|
import { useElectricClient } from "@/lib/electric/context";
|
||||||
|
|
||||||
export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
|
export type {
|
||||||
|
InboxItem,
|
||||||
|
InboxItemTypeEnum,
|
||||||
|
NotificationCategory,
|
||||||
|
} from "@/contracts/types/inbox.types";
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const INITIAL_PAGE_SIZE = 50;
|
||||||
const SYNC_WINDOW_DAYS = 14;
|
const SCROLL_PAGE_SIZE = 30;
|
||||||
|
const SYNC_WINDOW_DAYS = 4;
|
||||||
|
|
||||||
|
const CATEGORY_TYPE_SQL: Record<NotificationCategory, string> = {
|
||||||
|
comments: "AND type IN ('new_mention', 'comment_reply')",
|
||||||
|
status:
|
||||||
|
"AND type IN ('connector_indexing', 'connector_deletion', 'document_processing', 'page_limit_exceeded')",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an item is older than the sync window
|
* Calculate the cutoff date for sync window.
|
||||||
*/
|
* Rounds to the start of the day (midnight UTC) to ensure stable values
|
||||||
function isOlderThanSyncWindow(createdAt: string): boolean {
|
* across re-renders.
|
||||||
const cutoffDate = new Date();
|
|
||||||
cutoffDate.setDate(cutoffDate.getDate() - SYNC_WINDOW_DAYS);
|
|
||||||
return new Date(createdAt) < cutoffDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicate by ID and sort by created_at descending.
|
|
||||||
* This is the SINGLE source of truth for deduplication - prevents race conditions.
|
|
||||||
*/
|
|
||||||
function deduplicateAndSort(items: InboxItem[]): InboxItem[] {
|
|
||||||
const seen = new Map<number, InboxItem>();
|
|
||||||
for (const item of items) {
|
|
||||||
if (!seen.has(item.id)) {
|
|
||||||
seen.set(item.id, item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(seen.values()).sort(
|
|
||||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the cutoff date for sync window
|
|
||||||
* IMPORTANT: Rounds to the start of the day (midnight UTC) to ensure stable values
|
|
||||||
* across re-renders. Without this, millisecond differences cause multiple syncs!
|
|
||||||
*/
|
*/
|
||||||
function getSyncCutoffDate(): string {
|
function getSyncCutoffDate(): string {
|
||||||
const cutoff = new Date();
|
const cutoff = new Date();
|
||||||
cutoff.setDate(cutoff.getDate() - SYNC_WINDOW_DAYS);
|
cutoff.setDate(cutoff.getDate() - SYNC_WINDOW_DAYS);
|
||||||
// Round to start of day to prevent millisecond differences causing duplicate syncs
|
|
||||||
cutoff.setUTCHours(0, 0, 0, 0);
|
cutoff.setUTCHours(0, 0, 0, 0);
|
||||||
return cutoff.toISOString();
|
return cutoff.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a date value to ISO string format
|
* Hook for managing inbox items with API-first architecture + Electric real-time deltas.
|
||||||
*/
|
|
||||||
function toISOString(date: string | Date | null | undefined): string | null {
|
|
||||||
if (!date) return null;
|
|
||||||
if (date instanceof Date) return date.toISOString();
|
|
||||||
if (typeof date === "string") {
|
|
||||||
if (date.includes("T")) return date;
|
|
||||||
try {
|
|
||||||
return new Date(date).toISOString();
|
|
||||||
} catch {
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for managing inbox items with Electric SQL real-time sync + API fallback
|
|
||||||
*
|
*
|
||||||
* Architecture (Simplified & Race-Condition Free):
|
* Architecture (Documents pattern, per-tab):
|
||||||
* - Electric SQL: Syncs recent items (within SYNC_WINDOW_DAYS) for real-time updates
|
* 1. API is the PRIMARY data source — fetches first page on mount with category filter
|
||||||
* - Live Query: Provides reactive first page from PGLite
|
* 2. Electric provides REAL-TIME updates (new items, status changes, read state)
|
||||||
* - API: Handles all pagination (more reliable than mixing with Electric)
|
* 3. Baseline pattern prevents duplicates between API and Electric
|
||||||
|
* 4. Electric sync shape is SHARED across instances (client-level caching)
|
||||||
|
* — each instance creates its own type-filtered live queries
|
||||||
*
|
*
|
||||||
* Key Design Decisions:
|
* Unread count strategy:
|
||||||
* 1. No mutable refs for cursor - cursor computed from current state
|
* - API provides the category-filtered total on mount (ground truth across all time)
|
||||||
* 2. Single deduplicateAndSort function - prevents inconsistencies
|
* - Electric live query counts unread within SYNC_WINDOW_DAYS (filtered by type)
|
||||||
* 3. Filter-based preservation in live query - prevents data loss
|
* - olderUnreadOffsetRef bridges the gap: total = offset + recent
|
||||||
* 4. Auto-fetch from API when Electric returns 0 items
|
* - Optimistic updates adjust both the count and the offset (for old items)
|
||||||
*
|
*
|
||||||
* @param userId - The user ID to fetch inbox items for
|
* @param userId - The user ID to fetch inbox items for
|
||||||
* @param searchSpaceId - The search space ID to filter inbox items
|
* @param searchSpaceId - The search space ID to filter inbox items
|
||||||
* @param typeFilter - Optional inbox item type to filter by
|
* @param category - Which tab: "comments" or "status"
|
||||||
*/
|
*/
|
||||||
export function useInbox(
|
export function useInbox(
|
||||||
userId: string | null,
|
userId: string | null,
|
||||||
searchSpaceId: number | null,
|
searchSpaceId: number | null,
|
||||||
typeFilter: InboxItemTypeEnum | null = null
|
category: NotificationCategory
|
||||||
) {
|
) {
|
||||||
const electricClient = useElectricClient();
|
const electricClient = useElectricClient();
|
||||||
|
|
||||||
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
// Split unread count tracking for accurate counts with 14-day sync window
|
const initialLoadDoneRef = useRef(false);
|
||||||
// olderUnreadCount = unread items OLDER than sync window (from server, static until reconciliation)
|
const electricBaselineIdsRef = useRef<Set<number> | null>(null);
|
||||||
// recentUnreadCount = unread items within sync window (from live query, real-time)
|
const newestApiTimestampRef = useRef<string | null>(null);
|
||||||
const [olderUnreadCount, setOlderUnreadCount] = useState(0);
|
const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
|
||||||
const [recentUnreadCount, setRecentUnreadCount] = useState(0);
|
const unreadLiveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
|
||||||
|
|
||||||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
const olderUnreadOffsetRef = useRef<number | null>(null);
|
||||||
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
const apiUnreadTotalRef = useRef(0);
|
||||||
const userSyncKeyRef = useRef<string | null>(null);
|
|
||||||
const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
|
||||||
|
|
||||||
// Total unread = older (static from server) + recent (live from Electric)
|
// EFFECT 1: Fetch first page + unread count from API with category filter
|
||||||
const totalUnreadCount = olderUnreadCount + recentUnreadCount;
|
|
||||||
|
|
||||||
// EFFECT 1: Electric SQL sync for real-time updates
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId || !electricClient) {
|
if (!userId || !searchSpaceId) return;
|
||||||
setLoading(!electricClient);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setInboxItems([]);
|
||||||
|
setHasMore(false);
|
||||||
|
initialLoadDoneRef.current = false;
|
||||||
|
electricBaselineIdsRef.current = null;
|
||||||
|
newestApiTimestampRef.current = null;
|
||||||
|
olderUnreadOffsetRef.current = null;
|
||||||
|
apiUnreadTotalRef.current = 0;
|
||||||
|
|
||||||
|
const fetchInitialData = async () => {
|
||||||
|
try {
|
||||||
|
const [notificationsResponse, unreadResponse] = await Promise.all([
|
||||||
|
notificationsApiService.getNotifications({
|
||||||
|
queryParams: {
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
category,
|
||||||
|
limit: INITIAL_PAGE_SIZE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
notificationsApiService.getUnreadCount(searchSpaceId, undefined, category),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setInboxItems(notificationsResponse.items);
|
||||||
|
setHasMore(notificationsResponse.has_more);
|
||||||
|
setUnreadCount(unreadResponse.total_unread);
|
||||||
|
apiUnreadTotalRef.current = unreadResponse.total_unread;
|
||||||
|
newestApiTimestampRef.current = getNewestTimestamp(notificationsResponse.items);
|
||||||
|
setError(null);
|
||||||
|
initialLoadDoneRef.current = true;
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.error(`[useInbox:${category}] Initial load failed:`, err);
|
||||||
|
setError(err instanceof Error ? err : new Error("Failed to load notifications"));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchInitialData();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [userId, searchSpaceId, category]);
|
||||||
|
|
||||||
|
// EFFECT 2: Electric sync (shared shape) + per-instance type-filtered live queries
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId || !searchSpaceId || !electricClient) return;
|
||||||
|
|
||||||
|
const uid = userId;
|
||||||
|
const spaceId = searchSpaceId;
|
||||||
const client = electricClient;
|
const client = electricClient;
|
||||||
|
const typeFilter = CATEGORY_TYPE_SQL[category];
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
async function startSync() {
|
async function setupElectricRealtime() {
|
||||||
|
// Clean up previous live queries (NOT the sync shape — it's shared)
|
||||||
|
if (liveQueryRef.current) {
|
||||||
|
try {
|
||||||
|
liveQueryRef.current.unsubscribe?.();
|
||||||
|
} catch {
|
||||||
|
/* PGlite may be closed */
|
||||||
|
}
|
||||||
|
liveQueryRef.current = null;
|
||||||
|
}
|
||||||
|
if (unreadLiveQueryRef.current) {
|
||||||
|
try {
|
||||||
|
unreadLiveQueryRef.current.unsubscribe?.();
|
||||||
|
} catch {
|
||||||
|
/* PGlite may be closed */
|
||||||
|
}
|
||||||
|
unreadLiveQueryRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cutoffDate = getSyncCutoffDate();
|
const cutoffDate = getSyncCutoffDate();
|
||||||
const userSyncKey = `inbox_${userId}_${cutoffDate}`;
|
|
||||||
|
|
||||||
// Skip if already syncing with this key
|
|
||||||
if (userSyncKeyRef.current === userSyncKey) return;
|
|
||||||
|
|
||||||
// Clean up previous sync
|
|
||||||
if (syncHandleRef.current) {
|
|
||||||
try {
|
|
||||||
syncHandleRef.current.unsubscribe();
|
|
||||||
} catch {
|
|
||||||
// PGlite may already be closed during cleanup
|
|
||||||
}
|
|
||||||
syncHandleRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[useInbox] Starting sync for:", userId);
|
|
||||||
userSyncKeyRef.current = userSyncKey;
|
|
||||||
|
|
||||||
|
// Sync shape is cached by the Electric client — multiple hook instances
|
||||||
|
// calling syncShape with the same params get the same handle.
|
||||||
const handle = await client.syncShape({
|
const handle = await client.syncShape({
|
||||||
table: "notifications",
|
table: "notifications",
|
||||||
where: `user_id = '${userId}' AND created_at > '${cutoffDate}'`,
|
where: `user_id = '${uid}' AND created_at > '${cutoffDate}'`,
|
||||||
primaryKey: ["id"],
|
primaryKey: ["id"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for initial sync with timeout
|
if (!mounted) return;
|
||||||
|
|
||||||
if (!handle.isUpToDate && handle.initialSyncPromise) {
|
if (!handle.isUpToDate && handle.initialSyncPromise) {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
handle.initialSyncPromise,
|
handle.initialSyncPromise,
|
||||||
new Promise((resolve) => setTimeout(resolve, 3000)),
|
new Promise((resolve) => setTimeout(resolve, 5000)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
const db = client.db as {
|
||||||
|
live?: {
|
||||||
|
query: <T>(
|
||||||
|
sql: string,
|
||||||
|
params?: (number | string)[]
|
||||||
|
) => Promise<{
|
||||||
|
subscribe: (cb: (result: { rows: T[] }) => void) => void;
|
||||||
|
unsubscribe?: () => void;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!db.live?.query) return;
|
||||||
|
|
||||||
|
// Per-instance live query filtered by category types
|
||||||
|
const itemsQuery = `SELECT * FROM notifications
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND (search_space_id = $2 OR search_space_id IS NULL)
|
||||||
|
AND created_at > '${cutoffDate}'
|
||||||
|
${typeFilter}
|
||||||
|
ORDER BY created_at DESC`;
|
||||||
|
|
||||||
|
const liveQuery = await db.live.query<InboxItem>(itemsQuery, [uid, spaceId]);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
handle.unsubscribe();
|
liveQuery.unsubscribe?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
syncHandleRef.current = handle;
|
liveQuery.subscribe((result: { rows: InboxItem[] }) => {
|
||||||
setLoading(false);
|
if (!mounted || !result.rows || !initialLoadDoneRef.current) return;
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
if (!mounted) return;
|
|
||||||
console.error("[useInbox] Sync failed:", err);
|
|
||||||
setError(err instanceof Error ? err : new Error("Sync failed"));
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startSync();
|
const validItems = result.rows.filter((item) => item.id != null && item.title != null);
|
||||||
|
const cutoff = new Date(getSyncCutoffDate());
|
||||||
|
|
||||||
return () => {
|
const liveItemMap = new Map(validItems.map((d) => [d.id, d]));
|
||||||
mounted = false;
|
const liveIds = new Set(liveItemMap.keys());
|
||||||
userSyncKeyRef.current = null;
|
|
||||||
if (syncHandleRef.current) {
|
|
||||||
try {
|
|
||||||
syncHandleRef.current.unsubscribe();
|
|
||||||
} catch {
|
|
||||||
// PGlite may already be closed during cleanup
|
|
||||||
}
|
|
||||||
syncHandleRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [userId, electricClient]);
|
|
||||||
|
|
||||||
// Reset when filters change
|
setInboxItems((prev) => {
|
||||||
useEffect(() => {
|
const prevIds = new Set(prev.map((d) => d.id));
|
||||||
setHasMore(true);
|
|
||||||
setInboxItems([]);
|
|
||||||
// Reset count states - will be refetched by the unread count effect
|
|
||||||
setOlderUnreadCount(0);
|
|
||||||
setRecentUnreadCount(0);
|
|
||||||
}, [userId, searchSpaceId, typeFilter]);
|
|
||||||
|
|
||||||
// EFFECT 2: Live query for real-time updates + auto-fetch from API if empty
|
const newItems = filterNewElectricItems(
|
||||||
useEffect(() => {
|
validItems,
|
||||||
if (!userId || !electricClient) return;
|
liveIds,
|
||||||
|
prevIds,
|
||||||
const client = electricClient;
|
electricBaselineIdsRef,
|
||||||
let mounted = true;
|
newestApiTimestampRef.current
|
||||||
|
|
||||||
async function setupLiveQuery() {
|
|
||||||
// Clean up previous live query
|
|
||||||
if (liveQueryRef.current) {
|
|
||||||
try {
|
|
||||||
liveQueryRef.current.unsubscribe();
|
|
||||||
} catch {
|
|
||||||
// PGlite may already be closed during cleanup
|
|
||||||
}
|
|
||||||
liveQueryRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cutoff = getSyncCutoffDate();
|
|
||||||
|
|
||||||
const query = `SELECT * FROM notifications
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND (search_space_id = $2 OR search_space_id IS NULL)
|
|
||||||
AND created_at > '${cutoff}'
|
|
||||||
${typeFilter ? "AND type = $3" : ""}
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ${PAGE_SIZE}`;
|
|
||||||
|
|
||||||
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
|
|
||||||
|
|
||||||
const db = client.db as any;
|
|
||||||
|
|
||||||
// Initial fetch from PGLite - no validation needed, schema is enforced by Electric SQL sync
|
|
||||||
const result = await client.db.query<InboxItem>(query, params);
|
|
||||||
|
|
||||||
if (mounted && result.rows) {
|
|
||||||
const items = deduplicateAndSort(result.rows);
|
|
||||||
setInboxItems(items);
|
|
||||||
|
|
||||||
// AUTO-FETCH: If Electric returned 0 items, check API for older items
|
|
||||||
// This handles the edge case where user has no recent notifications
|
|
||||||
// but has older ones outside the sync window
|
|
||||||
if (items.length === 0) {
|
|
||||||
console.log(
|
|
||||||
"[useInbox] Electric returned 0 items, checking API for older notifications"
|
|
||||||
);
|
);
|
||||||
try {
|
|
||||||
// Use the API service with proper Zod validation for API responses
|
|
||||||
const data = await notificationsApiService.getNotifications({
|
|
||||||
queryParams: {
|
|
||||||
search_space_id: searchSpaceId ?? undefined,
|
|
||||||
type: typeFilter ?? undefined,
|
|
||||||
limit: PAGE_SIZE,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mounted) {
|
let updated = prev.map((item) => {
|
||||||
if (data.items.length > 0) {
|
const liveItem = liveItemMap.get(item.id);
|
||||||
setInboxItems(data.items);
|
if (liveItem) return liveItem;
|
||||||
}
|
return item;
|
||||||
setHasMore(data.has_more);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[useInbox] API fallback failed:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up live query for real-time updates
|
|
||||||
if (db.live?.query) {
|
|
||||||
const liveQuery = await db.live.query(query, params);
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
liveQuery.unsubscribe?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (liveQuery.subscribe) {
|
|
||||||
// Live query data comes from PGlite - no validation needed
|
|
||||||
liveQuery.subscribe((result: { rows: InboxItem[] }) => {
|
|
||||||
if (mounted && result.rows) {
|
|
||||||
const liveItems = result.rows;
|
|
||||||
|
|
||||||
setInboxItems((prev) => {
|
|
||||||
const liveItemIds = new Set(liveItems.map((item) => item.id));
|
|
||||||
|
|
||||||
// FIXED: Keep ALL items not in live result (not just slice)
|
|
||||||
// This prevents data loss when new notifications push items
|
|
||||||
// out of the LIMIT window
|
|
||||||
const itemsToKeep = prev.filter((item) => !liveItemIds.has(item.id));
|
|
||||||
|
|
||||||
return deduplicateAndSort([...liveItems, ...itemsToKeep]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isFullySynced = handle.isUpToDate;
|
||||||
|
if (isFullySynced) {
|
||||||
|
updated = updated.filter((item) => {
|
||||||
|
if (new Date(item.created_at) < cutoff) return true;
|
||||||
|
return liveIds.has(item.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newItems.length > 0) {
|
||||||
|
return [...newItems, ...updated];
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calibrate the older-unread offset using baseline items
|
||||||
|
// (items present in both Electric and the API-loaded list).
|
||||||
|
// This avoids the timing bug where new items arriving between
|
||||||
|
// the API fetch and Electric's first callback would be absorbed
|
||||||
|
// into the offset, making the count appear unchanged.
|
||||||
|
const baseline = electricBaselineIdsRef.current;
|
||||||
|
if (olderUnreadOffsetRef.current === null && baseline !== null) {
|
||||||
|
const baselineUnreadCount = validItems.filter(
|
||||||
|
(item) => baseline.has(item.id) && !item.read
|
||||||
|
).length;
|
||||||
|
olderUnreadOffsetRef.current = Math.max(
|
||||||
|
0,
|
||||||
|
apiUnreadTotalRef.current - baselineUnreadCount
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (liveQuery.unsubscribe) {
|
// Derive unread count from all Electric items + the older offset
|
||||||
liveQueryRef.current = liveQuery;
|
if (olderUnreadOffsetRef.current !== null) {
|
||||||
|
const electricUnreadCount = validItems.filter((item) => !item.read).length;
|
||||||
|
setUnreadCount(olderUnreadOffsetRef.current + electricUnreadCount);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
liveQueryRef.current = liveQuery;
|
||||||
|
|
||||||
|
// Per-instance unread count live query filtered by category types.
|
||||||
|
// Acts as a secondary reactive path for read-status changes that
|
||||||
|
// may not trigger the items live query in all edge cases.
|
||||||
|
const countQuery = `SELECT COUNT(*) as count FROM notifications
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND (search_space_id = $2 OR search_space_id IS NULL)
|
||||||
|
AND created_at > '${cutoffDate}'
|
||||||
|
AND read = false
|
||||||
|
${typeFilter}`;
|
||||||
|
|
||||||
|
const countLiveQuery = await db.live.query<{ count: number | string }>(countQuery, [
|
||||||
|
uid,
|
||||||
|
spaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
countLiveQuery.unsubscribe?.();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
countLiveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => {
|
||||||
|
if (!mounted || !result.rows?.[0] || !initialLoadDoneRef.current) return;
|
||||||
|
if (olderUnreadOffsetRef.current === null) return;
|
||||||
|
const liveRecentUnread = Number(result.rows[0].count) || 0;
|
||||||
|
setUnreadCount(olderUnreadOffsetRef.current + liveRecentUnread);
|
||||||
|
});
|
||||||
|
|
||||||
|
unreadLiveQueryRef.current = countLiveQuery;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[useInbox] Live query error:", err);
|
console.error(`[useInbox:${category}] Electric setup failed:`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupLiveQuery();
|
setupElectricRealtime();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
|
// Only clean up live queries — sync shape is shared across instances
|
||||||
if (liveQueryRef.current) {
|
if (liveQueryRef.current) {
|
||||||
try {
|
try {
|
||||||
liveQueryRef.current.unsubscribe();
|
liveQueryRef.current.unsubscribe?.();
|
||||||
} catch {
|
} catch {
|
||||||
// PGlite may already be closed during cleanup
|
/* PGlite may be closed */
|
||||||
}
|
}
|
||||||
liveQueryRef.current = null;
|
liveQueryRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
if (unreadLiveQueryRef.current) {
|
||||||
}, [userId, searchSpaceId, typeFilter, electricClient]);
|
try {
|
||||||
|
unreadLiveQueryRef.current.unsubscribe?.();
|
||||||
// EFFECT 3: Dedicated unread count sync with split tracking
|
} catch {
|
||||||
// - Fetches server count on mount (accurate total)
|
/* PGlite may be closed */
|
||||||
// - Sets up live query for recent count (real-time updates)
|
|
||||||
// - Handles items older than sync window separately
|
|
||||||
useEffect(() => {
|
|
||||||
if (!userId || !electricClient) return;
|
|
||||||
|
|
||||||
const client = electricClient;
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
async function setupUnreadCountSync() {
|
|
||||||
// Cleanup previous live query
|
|
||||||
if (unreadCountLiveQueryRef.current) {
|
|
||||||
unreadCountLiveQueryRef.current.unsubscribe();
|
|
||||||
unreadCountLiveQueryRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// STEP 1: Fetch server counts (total and recent) - guaranteed accurate
|
|
||||||
console.log(
|
|
||||||
"[useInbox] Fetching unread count from server",
|
|
||||||
typeFilter ? `for type: ${typeFilter}` : "for all types"
|
|
||||||
);
|
|
||||||
const serverCounts = await notificationsApiService.getUnreadCount(
|
|
||||||
searchSpaceId ?? undefined,
|
|
||||||
typeFilter ?? undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
// Calculate older count = total - recent
|
|
||||||
const olderCount = serverCounts.total_unread - serverCounts.recent_unread;
|
|
||||||
setOlderUnreadCount(olderCount);
|
|
||||||
setRecentUnreadCount(serverCounts.recent_unread);
|
|
||||||
console.log(
|
|
||||||
`[useInbox] Server counts: total=${serverCounts.total_unread}, recent=${serverCounts.recent_unread}, older=${olderCount}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
unreadLiveQueryRef.current = null;
|
||||||
// STEP 2: Set up PGLite live query for RECENT unread count only
|
|
||||||
// This provides real-time updates for notifications within sync window
|
|
||||||
const db = client.db as any;
|
|
||||||
const cutoff = getSyncCutoffDate();
|
|
||||||
|
|
||||||
// Count query - NO LIMIT, counts all unread in synced window
|
|
||||||
const countQuery = `
|
|
||||||
SELECT COUNT(*) as count FROM notifications
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND (search_space_id = $2 OR search_space_id IS NULL)
|
|
||||||
AND created_at > '${cutoff}'
|
|
||||||
AND read = false
|
|
||||||
${typeFilter ? "AND type = $3" : ""}
|
|
||||||
`;
|
|
||||||
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
|
|
||||||
|
|
||||||
if (db.live?.query) {
|
|
||||||
const liveQuery = await db.live.query(countQuery, params);
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
liveQuery.unsubscribe?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (liveQuery.subscribe) {
|
|
||||||
liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => {
|
|
||||||
if (mounted && result.rows?.[0]) {
|
|
||||||
const liveCount = Number(result.rows[0].count) || 0;
|
|
||||||
// Update recent count from live query
|
|
||||||
// This fires in real-time when Electric syncs new/updated notifications
|
|
||||||
setRecentUnreadCount(liveCount);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (liveQuery.unsubscribe) {
|
|
||||||
unreadCountLiveQueryRef.current = liveQuery;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[useInbox] Unread count sync error:", err);
|
|
||||||
// On error, counts will remain at 0 or previous values
|
|
||||||
// The items-based count will be the fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setupUnreadCountSync();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
if (unreadCountLiveQueryRef.current) {
|
|
||||||
unreadCountLiveQueryRef.current.unsubscribe();
|
|
||||||
unreadCountLiveQueryRef.current = null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [userId, searchSpaceId, typeFilter, electricClient]);
|
}, [userId, searchSpaceId, electricClient, category]);
|
||||||
|
|
||||||
// loadMore - Pure cursor-based pagination, no race conditions
|
// Load more pages via API (cursor-based using before_date)
|
||||||
// Cursor is computed from current state, not stored in refs
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
// Removed inboxItems.length === 0 check to allow loading older items
|
if (loadingMore || !hasMore || !userId || !searchSpaceId) return;
|
||||||
// when Electric returns 0 items
|
|
||||||
if (!userId || loadingMore || !hasMore) return;
|
|
||||||
|
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Cursor is computed from current state - no stale refs possible
|
|
||||||
const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null;
|
const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null;
|
||||||
const beforeDate = oldestItem ? toISOString(oldestItem.created_at) : null;
|
const beforeDate = oldestItem?.created_at ?? undefined;
|
||||||
|
|
||||||
console.log("[useInbox] Loading more, before:", beforeDate ?? "none (initial)");
|
const response = await notificationsApiService.getNotifications({
|
||||||
|
|
||||||
// Use the API service with proper Zod validation
|
|
||||||
const data = await notificationsApiService.getNotifications({
|
|
||||||
queryParams: {
|
queryParams: {
|
||||||
search_space_id: searchSpaceId ?? undefined,
|
search_space_id: searchSpaceId,
|
||||||
type: typeFilter ?? undefined,
|
category,
|
||||||
before_date: beforeDate ?? undefined,
|
before_date: beforeDate,
|
||||||
limit: PAGE_SIZE,
|
limit: SCROLL_PAGE_SIZE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.items.length > 0) {
|
const newItems = response.items;
|
||||||
// Functional update ensures we always merge with latest state
|
|
||||||
// Items are already validated by the API service
|
|
||||||
setInboxItems((prev) => deduplicateAndSort([...prev, ...data.items]));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use API's has_more flag
|
setInboxItems((prev) => {
|
||||||
setHasMore(data.has_more);
|
const existingIds = new Set(prev.map((d) => d.id));
|
||||||
|
const deduped = newItems.filter((d) => !existingIds.has(d.id));
|
||||||
|
return [...prev, ...deduped];
|
||||||
|
});
|
||||||
|
setHasMore(response.has_more);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[useInbox] Load more failed:", err);
|
console.error(`[useInbox:${category}] Load more failed:`, err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
}, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]);
|
}, [loadingMore, hasMore, userId, searchSpaceId, inboxItems, category]);
|
||||||
|
|
||||||
// Mark inbox item as read with optimistic update
|
// Mark single item as read with optimistic update
|
||||||
// Handles both recent items (live query updates count) and older items (manual count decrement)
|
|
||||||
const markAsRead = useCallback(
|
const markAsRead = useCallback(
|
||||||
async (itemId: number) => {
|
async (itemId: number) => {
|
||||||
// Find the item to check if it's older than sync window
|
|
||||||
const item = inboxItems.find((i) => i.id === itemId);
|
const item = inboxItems.find((i) => i.id === itemId);
|
||||||
const isOlderItem = item && !item.read && isOlderThanSyncWindow(item.created_at);
|
if (!item || item.read) return true;
|
||||||
|
|
||||||
|
const cutoff = new Date(getSyncCutoffDate());
|
||||||
|
const isOlderItem = new Date(item.created_at) < cutoff;
|
||||||
|
|
||||||
// Optimistic update: mark as read immediately for instant UI feedback
|
|
||||||
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i)));
|
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i)));
|
||||||
|
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||||
|
|
||||||
// If older item, manually decrement older count
|
if (isOlderItem && olderUnreadOffsetRef.current !== null) {
|
||||||
// (live query won't see items outside sync window)
|
olderUnreadOffsetRef.current = Math.max(0, olderUnreadOffsetRef.current - 1);
|
||||||
if (isOlderItem) {
|
|
||||||
setOlderUnreadCount((prev) => Math.max(0, prev - 1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the API service with proper Zod validation
|
|
||||||
const result = await notificationsApiService.markAsRead({ notificationId: itemId });
|
const result = await notificationsApiService.markAsRead({ notificationId: itemId });
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
// Rollback on error
|
|
||||||
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
|
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
|
||||||
if (isOlderItem) {
|
setUnreadCount((prev) => prev + 1);
|
||||||
setOlderUnreadCount((prev) => prev + 1);
|
if (isOlderItem && olderUnreadOffsetRef.current !== null) {
|
||||||
|
olderUnreadOffsetRef.current += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If successful, Electric SQL will sync the change and live query will update
|
|
||||||
// This ensures eventual consistency even if optimistic update was wrong
|
|
||||||
return result.success;
|
return result.success;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to mark as read:", err);
|
console.error("Failed to mark as read:", err);
|
||||||
// Rollback on error
|
|
||||||
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
|
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
|
||||||
if (isOlderItem) {
|
setUnreadCount((prev) => prev + 1);
|
||||||
setOlderUnreadCount((prev) => prev + 1);
|
if (isOlderItem && olderUnreadOffsetRef.current !== null) {
|
||||||
|
olderUnreadOffsetRef.current += 1;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -499,49 +404,42 @@ export function useInbox(
|
||||||
[inboxItems]
|
[inboxItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark all inbox items as read with optimistic update
|
// Mark all as read with optimistic update
|
||||||
// Resets both older and recent counts to 0
|
|
||||||
const markAllAsRead = useCallback(async () => {
|
const markAllAsRead = useCallback(async () => {
|
||||||
// Store previous counts for potential rollback
|
const prevItems = inboxItems;
|
||||||
const prevOlderCount = olderUnreadCount;
|
const prevCount = unreadCount;
|
||||||
const prevRecentCount = recentUnreadCount;
|
const prevOffset = olderUnreadOffsetRef.current;
|
||||||
|
|
||||||
// Optimistic update: mark all as read immediately for instant UI feedback
|
|
||||||
setInboxItems((prev) => prev.map((item) => ({ ...item, read: true })));
|
setInboxItems((prev) => prev.map((item) => ({ ...item, read: true })));
|
||||||
setOlderUnreadCount(0);
|
setUnreadCount(0);
|
||||||
setRecentUnreadCount(0);
|
olderUnreadOffsetRef.current = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the API service with proper Zod validation
|
|
||||||
const result = await notificationsApiService.markAllAsRead();
|
const result = await notificationsApiService.markAllAsRead();
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error("Failed to mark all as read");
|
setInboxItems(prevItems);
|
||||||
// Rollback counts on error
|
setUnreadCount(prevCount);
|
||||||
setOlderUnreadCount(prevOlderCount);
|
olderUnreadOffsetRef.current = prevOffset;
|
||||||
setRecentUnreadCount(prevRecentCount);
|
|
||||||
}
|
}
|
||||||
// Electric SQL will sync and live query will ensure consistency
|
|
||||||
return result.success;
|
return result.success;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to mark all as read:", err);
|
console.error("Failed to mark all as read:", err);
|
||||||
// Rollback counts on error
|
setInboxItems(prevItems);
|
||||||
setOlderUnreadCount(prevOlderCount);
|
setUnreadCount(prevCount);
|
||||||
setRecentUnreadCount(prevRecentCount);
|
olderUnreadOffsetRef.current = prevOffset;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [olderUnreadCount, recentUnreadCount]);
|
}, [inboxItems, unreadCount]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inboxItems,
|
inboxItems,
|
||||||
unreadCount: totalUnreadCount,
|
unreadCount,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
loading,
|
loading,
|
||||||
loadingMore,
|
loadingMore,
|
||||||
hasMore,
|
hasMore,
|
||||||
loadMore,
|
loadMore,
|
||||||
isUsingApiFallback: true, // Always use API for pagination
|
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
surfsense_web/hooks/use-long-press.ts
Normal file
47
surfsense_web/hooks/use-long-press.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const LONG_PRESS_DELAY = 500;
|
||||||
|
|
||||||
|
export function useLongPress(onLongPress: () => void, delay = LONG_PRESS_DELAY) {
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const triggeredRef = useRef(false);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
triggeredRef.current = false;
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
triggeredRef.current = true;
|
||||||
|
onLongPress();
|
||||||
|
}, delay);
|
||||||
|
}, [onLongPress, delay]);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
onTouchStart: start,
|
||||||
|
onTouchEnd: cancel,
|
||||||
|
onTouchMove: cancel,
|
||||||
|
};
|
||||||
|
|
||||||
|
const wasLongPress = useCallback(() => {
|
||||||
|
if (triggeredRef.current) {
|
||||||
|
triggeredRef.current = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { handlers, wasLongPress };
|
||||||
|
}
|
||||||
|
|
@ -33,25 +33,37 @@ export function usePlatformShortcut() {
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const shortcut = useCallback(
|
const resolveKeys = useCallback(
|
||||||
(...keys: string[]) => {
|
(keys: string[]) => {
|
||||||
if (!ready) return "";
|
|
||||||
|
|
||||||
const mod = isMac ? "⌘" : "Ctrl";
|
const mod = isMac ? "⌘" : "Ctrl";
|
||||||
const shift = isMac ? "⇧" : "Shift";
|
const shift = isMac ? "⇧" : "Shift";
|
||||||
const alt = isMac ? "⌥" : "Alt";
|
const alt = isMac ? "⌥" : "Alt";
|
||||||
|
|
||||||
const mapped = keys.map((k) => {
|
return keys.map((k) => {
|
||||||
if (k === "Mod") return mod;
|
if (k === "Mod") return mod;
|
||||||
if (k === "Shift") return shift;
|
if (k === "Shift") return shift;
|
||||||
if (k === "Alt") return alt;
|
if (k === "Alt") return alt;
|
||||||
return k;
|
return k;
|
||||||
});
|
});
|
||||||
|
|
||||||
return `(${mapped.join("+")})`;
|
|
||||||
},
|
},
|
||||||
[ready, isMac]
|
[isMac]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { shortcut, isMac, ready };
|
const shortcut = useCallback(
|
||||||
|
(...keys: string[]) => {
|
||||||
|
if (!ready) return "";
|
||||||
|
return `(${resolveKeys(keys).join("+")})`;
|
||||||
|
},
|
||||||
|
[ready, resolveKeys]
|
||||||
|
);
|
||||||
|
|
||||||
|
const shortcutKeys = useCallback(
|
||||||
|
(...keys: string[]) => {
|
||||||
|
if (!ready) return [];
|
||||||
|
return resolveKeys(keys);
|
||||||
|
},
|
||||||
|
[ready, resolveKeys]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { shortcut, shortcutKeys, isMac, ready };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import {
|
import {
|
||||||
type GetNotificationsRequest,
|
type GetNotificationsRequest,
|
||||||
type GetNotificationsResponse,
|
type GetNotificationsResponse,
|
||||||
|
type GetSourceTypesResponse,
|
||||||
type GetUnreadCountResponse,
|
type GetUnreadCountResponse,
|
||||||
getNotificationsRequest,
|
getNotificationsRequest,
|
||||||
getNotificationsResponse,
|
getNotificationsResponse,
|
||||||
|
getSourceTypesResponse,
|
||||||
getUnreadCountResponse,
|
getUnreadCountResponse,
|
||||||
type InboxItemTypeEnum,
|
type InboxItemTypeEnum,
|
||||||
type MarkAllNotificationsReadResponse,
|
type MarkAllNotificationsReadResponse,
|
||||||
|
|
@ -12,6 +14,7 @@ import {
|
||||||
markAllNotificationsReadResponse,
|
markAllNotificationsReadResponse,
|
||||||
markNotificationReadRequest,
|
markNotificationReadRequest,
|
||||||
markNotificationReadResponse,
|
markNotificationReadResponse,
|
||||||
|
type NotificationCategory,
|
||||||
} from "@/contracts/types/inbox.types";
|
} from "@/contracts/types/inbox.types";
|
||||||
import { ValidationError } from "../error";
|
import { ValidationError } from "../error";
|
||||||
import { baseApiService } from "./base-api.service";
|
import { baseApiService } from "./base-api.service";
|
||||||
|
|
@ -42,6 +45,15 @@ class NotificationsApiService {
|
||||||
if (queryParams.type) {
|
if (queryParams.type) {
|
||||||
params.append("type", queryParams.type);
|
params.append("type", queryParams.type);
|
||||||
}
|
}
|
||||||
|
if (queryParams.category) {
|
||||||
|
params.append("category", queryParams.category);
|
||||||
|
}
|
||||||
|
if (queryParams.source_type) {
|
||||||
|
params.append("source_type", queryParams.source_type);
|
||||||
|
}
|
||||||
|
if (queryParams.filter) {
|
||||||
|
params.append("filter", queryParams.filter);
|
||||||
|
}
|
||||||
if (queryParams.before_date) {
|
if (queryParams.before_date) {
|
||||||
params.append("before_date", queryParams.before_date);
|
params.append("before_date", queryParams.before_date);
|
||||||
}
|
}
|
||||||
|
|
@ -92,16 +104,33 @@ class NotificationsApiService {
|
||||||
return baseApiService.patch("/api/v1/notifications/read-all", markAllNotificationsReadResponse);
|
return baseApiService.patch("/api/v1/notifications/read-all", markAllNotificationsReadResponse);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get distinct source types (connector + document types) across all
|
||||||
|
* status notifications. Used to populate the inbox Status tab filter.
|
||||||
|
*/
|
||||||
|
getSourceTypes = async (searchSpaceId?: number): Promise<GetSourceTypesResponse> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (searchSpaceId !== undefined) {
|
||||||
|
params.append("search_space_id", String(searchSpaceId));
|
||||||
|
}
|
||||||
|
const queryString = params.toString();
|
||||||
|
|
||||||
|
return baseApiService.get(
|
||||||
|
`/api/v1/notifications/source-types${queryString ? `?${queryString}` : ""}`,
|
||||||
|
getSourceTypesResponse
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get unread notification count with split between total and recent
|
* Get unread notification count with split between total and recent
|
||||||
* - total_unread: All unread notifications
|
|
||||||
* - recent_unread: Unread within sync window (last 14 days)
|
|
||||||
* @param searchSpaceId - Optional search space ID to filter by
|
* @param searchSpaceId - Optional search space ID to filter by
|
||||||
* @param type - Optional notification type to filter by (type-safe enum)
|
* @param type - Optional notification type to filter by (type-safe enum)
|
||||||
|
* @param category - Optional category filter ('comments' or 'status')
|
||||||
*/
|
*/
|
||||||
getUnreadCount = async (
|
getUnreadCount = async (
|
||||||
searchSpaceId?: number,
|
searchSpaceId?: number,
|
||||||
type?: InboxItemTypeEnum
|
type?: InboxItemTypeEnum,
|
||||||
|
category?: NotificationCategory
|
||||||
): Promise<GetUnreadCountResponse> => {
|
): Promise<GetUnreadCountResponse> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (searchSpaceId !== undefined) {
|
if (searchSpaceId !== undefined) {
|
||||||
|
|
@ -110,6 +139,9 @@ class NotificationsApiService {
|
||||||
if (type) {
|
if (type) {
|
||||||
params.append("type", type);
|
params.append("type", type);
|
||||||
}
|
}
|
||||||
|
if (category) {
|
||||||
|
params.append("category", category);
|
||||||
|
}
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
|
|
||||||
return baseApiService.get(
|
return baseApiService.get(
|
||||||
|
|
|
||||||
62
surfsense_web/lib/electric/baseline.ts
Normal file
62
surfsense_web/lib/electric/baseline.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import type { MutableRefObject } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the newest `created_at` timestamp from a list of items.
|
||||||
|
* Used to establish the server-clock cutoff for the baseline timing-gap check.
|
||||||
|
*
|
||||||
|
* Uses Date parsing instead of string comparison because the API (Python
|
||||||
|
* isoformat: "+00:00" suffix) and Electric/PGlite ("Z" suffix, variable
|
||||||
|
* fractional-second precision) produce different string formats.
|
||||||
|
*/
|
||||||
|
export function getNewestTimestamp<T extends { created_at: string }>(items: T[]): string | null {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
let newest = items[0].created_at;
|
||||||
|
let newestMs = new Date(newest).getTime();
|
||||||
|
for (let i = 1; i < items.length; i++) {
|
||||||
|
const ms = new Date(items[i].created_at).getTime();
|
||||||
|
if (ms > newestMs) {
|
||||||
|
newest = items[i].created_at;
|
||||||
|
newestMs = ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify genuinely new items from an Electric live query callback.
|
||||||
|
*
|
||||||
|
* On Electric's first callback, ALL live IDs are snapshotted as the baseline.
|
||||||
|
* Items beyond the API's first page are in this baseline and stay hidden
|
||||||
|
* (they'll appear via scroll pagination). Items created in the timing gap
|
||||||
|
* between the API fetch and Electric's first callback are rescued via the
|
||||||
|
* `newestApiTimestamp` check — their `created_at` is newer than anything
|
||||||
|
* the API returned, so they pass through.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export function filterNewElectricItems<T extends { id: number; created_at: string }>(
|
||||||
|
validItems: T[],
|
||||||
|
liveIds: Set<number>,
|
||||||
|
prevIds: Set<number>,
|
||||||
|
baselineRef: MutableRefObject<Set<number> | null>,
|
||||||
|
newestApiTimestamp: string | null
|
||||||
|
): T[] {
|
||||||
|
if (baselineRef.current === null) {
|
||||||
|
baselineRef.current = new Set(liveIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseline = baselineRef.current;
|
||||||
|
const cutoffMs = newestApiTimestamp ? new Date(newestApiTimestamp).getTime() : null;
|
||||||
|
|
||||||
|
const newItems = validItems.filter((item) => {
|
||||||
|
if (prevIds.has(item.id)) return false;
|
||||||
|
if (!baseline.has(item.id)) return true;
|
||||||
|
if (cutoffMs !== null && new Date(item.created_at).getTime() > cutoffMs) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of newItems) {
|
||||||
|
baseline.add(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newItems;
|
||||||
|
}
|
||||||
|
|
@ -71,7 +71,8 @@ const pendingSyncs = new Map<string, Promise<SyncHandle>>();
|
||||||
// real-time documents table with title/created_by_id/status columns,
|
// real-time documents table with title/created_by_id/status columns,
|
||||||
// consolidated single documents sync, pending state for document queue visibility
|
// consolidated single documents sync, pending state for document queue visibility
|
||||||
// v6: added enable_summary column to search_source_connectors
|
// v6: added enable_summary column to search_source_connectors
|
||||||
const SYNC_VERSION = 6;
|
// v7: fixed connector-popup using invalid category for useInbox
|
||||||
|
const SYNC_VERSION = 7;
|
||||||
|
|
||||||
// Database name prefix for identifying SurfSense databases
|
// Database name prefix for identifying SurfSense databases
|
||||||
const DB_PREFIX = "surfsense-";
|
const DB_PREFIX = "surfsense-";
|
||||||
|
|
|
||||||
|
|
@ -96,5 +96,7 @@ export const cacheKeys = {
|
||||||
notifications: {
|
notifications: {
|
||||||
search: (searchSpaceId: number | null, search: string, tab: string) =>
|
search: (searchSpaceId: number | null, search: string, tab: string) =>
|
||||||
["notifications", "search", searchSpaceId, search, tab] as const,
|
["notifications", "search", searchSpaceId, search, tab] as const,
|
||||||
|
sourceTypes: (searchSpaceId: number | null) =>
|
||||||
|
["notifications", "source-types", searchSpaceId] as const,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,8 @@
|
||||||
"upload_documents": "Upload Documents",
|
"upload_documents": "Upload Documents",
|
||||||
"create_shared_note": "Create Shared Note",
|
"create_shared_note": "Create Shared Note",
|
||||||
"processing_documents": "Processing documents...",
|
"processing_documents": "Processing documents...",
|
||||||
"active_tasks_count": "{count} active task(s)"
|
"delete_in_progress_warning": "{count} document(s) are pending or processing and cannot be deleted.",
|
||||||
|
"delete_conflict_error": "{count} document(s) started processing. Please try again later."
|
||||||
},
|
},
|
||||||
"add_connector": {
|
"add_connector": {
|
||||||
"title": "Connect Your Tools",
|
"title": "Connect Your Tools",
|
||||||
|
|
@ -637,23 +638,6 @@
|
||||||
"add_first_config": "Add First Configuration",
|
"add_first_config": "Add First Configuration",
|
||||||
"created": "Created"
|
"created": "Created"
|
||||||
},
|
},
|
||||||
"breadcrumb": {
|
|
||||||
"dashboard": "Dashboard",
|
|
||||||
"search_space": "Search Space",
|
|
||||||
"chat": "Chat",
|
|
||||||
"documents": "Documents",
|
|
||||||
"connectors": "Connectors",
|
|
||||||
"editor": "Editor",
|
|
||||||
"logs": "Logs",
|
|
||||||
"settings": "Settings",
|
|
||||||
"upload_documents": "Upload Documents",
|
|
||||||
"add_youtube": "Add YouTube Videos",
|
|
||||||
"add_webpages": "Add Webpages",
|
|
||||||
"add_connector": "Add Connector",
|
|
||||||
"manage_connectors": "Manage Connectors",
|
|
||||||
"edit_connector": "Edit Connector",
|
|
||||||
"manage": "Manage"
|
|
||||||
},
|
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"chats": "Private Chats",
|
"chats": "Private Chats",
|
||||||
"shared_chats": "Shared Chats",
|
"shared_chats": "Shared Chats",
|
||||||
|
|
@ -718,8 +702,11 @@
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"unread": "Unread",
|
"unread": "Unread",
|
||||||
|
"errors_only": "Errors only",
|
||||||
"connectors": "Connectors",
|
"connectors": "Connectors",
|
||||||
"all_connectors": "All connectors",
|
"all_connectors": "All connectors",
|
||||||
|
"sources": "Sources",
|
||||||
|
"all_sources": "All sources",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,8 @@
|
||||||
"upload_documents": "Subir documentos",
|
"upload_documents": "Subir documentos",
|
||||||
"create_shared_note": "Crear nota compartida",
|
"create_shared_note": "Crear nota compartida",
|
||||||
"processing_documents": "Procesando documentos...",
|
"processing_documents": "Procesando documentos...",
|
||||||
"active_tasks_count": "{count} tarea(s) activa(s)"
|
"delete_in_progress_warning": "{count} documento(s) están pendientes o en proceso y no se pueden eliminar.",
|
||||||
|
"delete_conflict_error": "{count} documento(s) comenzaron a procesarse. Inténtelo de nuevo más tarde."
|
||||||
},
|
},
|
||||||
"add_connector": {
|
"add_connector": {
|
||||||
"title": "Conecta tus herramientas",
|
"title": "Conecta tus herramientas",
|
||||||
|
|
@ -637,23 +638,6 @@
|
||||||
"add_first_config": "Agregar primera configuración",
|
"add_first_config": "Agregar primera configuración",
|
||||||
"created": "Creado"
|
"created": "Creado"
|
||||||
},
|
},
|
||||||
"breadcrumb": {
|
|
||||||
"dashboard": "Panel de control",
|
|
||||||
"search_space": "Espacio de búsqueda",
|
|
||||||
"chat": "Chat",
|
|
||||||
"documents": "Documentos",
|
|
||||||
"connectors": "Conectores",
|
|
||||||
"editor": "Editor",
|
|
||||||
"logs": "Registros",
|
|
||||||
"settings": "Configuración",
|
|
||||||
"upload_documents": "Subir documentos",
|
|
||||||
"add_youtube": "Agregar videos de YouTube",
|
|
||||||
"add_webpages": "Agregar páginas web",
|
|
||||||
"add_connector": "Agregar conector",
|
|
||||||
"manage_connectors": "Administrar conectores",
|
|
||||||
"edit_connector": "Editar conector",
|
|
||||||
"manage": "Administrar"
|
|
||||||
},
|
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"chats": "Chats privados",
|
"chats": "Chats privados",
|
||||||
"shared_chats": "Chats compartidos",
|
"shared_chats": "Chats compartidos",
|
||||||
|
|
@ -718,8 +702,11 @@
|
||||||
"filter": "Filtrar",
|
"filter": "Filtrar",
|
||||||
"all": "Todo",
|
"all": "Todo",
|
||||||
"unread": "No leído",
|
"unread": "No leído",
|
||||||
|
"errors_only": "Solo errores",
|
||||||
"connectors": "Conectores",
|
"connectors": "Conectores",
|
||||||
"all_connectors": "Todos los conectores",
|
"all_connectors": "Todos los conectores",
|
||||||
|
"sources": "Fuentes",
|
||||||
|
"all_sources": "Todas las fuentes",
|
||||||
"close": "Cerrar",
|
"close": "Cerrar",
|
||||||
"cancel": "Cancelar"
|
"cancel": "Cancelar"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,8 @@
|
||||||
"upload_documents": "दस्तावेज़ अपलोड करें",
|
"upload_documents": "दस्तावेज़ अपलोड करें",
|
||||||
"create_shared_note": "साझा नोट बनाएं",
|
"create_shared_note": "साझा नोट बनाएं",
|
||||||
"processing_documents": "दस्तावेज़ प्रोसेस हो रहे हैं...",
|
"processing_documents": "दस्तावेज़ प्रोसेस हो रहे हैं...",
|
||||||
"active_tasks_count": "{count} सक्रिय कार्य"
|
"delete_in_progress_warning": "{count} दस्तावेज़ लंबित या प्रसंस्करण में हैं और हटाए नहीं जा सकते।",
|
||||||
|
"delete_conflict_error": "{count} दस्तावेज़ प्रसंस्करण शुरू हो गया है। कृपया बाद में पुनः प्रयास करें।"
|
||||||
},
|
},
|
||||||
"add_connector": {
|
"add_connector": {
|
||||||
"title": "अपने टूल कनेक्ट करें",
|
"title": "अपने टूल कनेक्ट करें",
|
||||||
|
|
@ -637,23 +638,6 @@
|
||||||
"add_first_config": "पहली कॉन्फ़िगरेशन जोड़ें",
|
"add_first_config": "पहली कॉन्फ़िगरेशन जोड़ें",
|
||||||
"created": "बनाया गया"
|
"created": "बनाया गया"
|
||||||
},
|
},
|
||||||
"breadcrumb": {
|
|
||||||
"dashboard": "डैशबोर्ड",
|
|
||||||
"search_space": "सर्च स्पेस",
|
|
||||||
"chat": "चैट",
|
|
||||||
"documents": "दस्तावेज़",
|
|
||||||
"connectors": "कनेक्टर",
|
|
||||||
"editor": "एडिटर",
|
|
||||||
"logs": "लॉग",
|
|
||||||
"settings": "सेटिंग्स",
|
|
||||||
"upload_documents": "दस्तावेज़ अपलोड करें",
|
|
||||||
"add_youtube": "YouTube वीडियो जोड़ें",
|
|
||||||
"add_webpages": "वेबपेज जोड़ें",
|
|
||||||
"add_connector": "कनेक्टर जोड़ें",
|
|
||||||
"manage_connectors": "कनेक्टर प्रबंधित करें",
|
|
||||||
"edit_connector": "कनेक्टर संपादित करें",
|
|
||||||
"manage": "प्रबंधित करें"
|
|
||||||
},
|
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"chats": "निजी चैट",
|
"chats": "निजी चैट",
|
||||||
"shared_chats": "साझा चैट",
|
"shared_chats": "साझा चैट",
|
||||||
|
|
@ -718,8 +702,11 @@
|
||||||
"filter": "फ़िल्टर",
|
"filter": "फ़िल्टर",
|
||||||
"all": "सभी",
|
"all": "सभी",
|
||||||
"unread": "अपठित",
|
"unread": "अपठित",
|
||||||
|
"errors_only": "केवल त्रुटियाँ",
|
||||||
"connectors": "कनेक्टर",
|
"connectors": "कनेक्टर",
|
||||||
"all_connectors": "सभी कनेक्टर",
|
"all_connectors": "सभी कनेक्टर",
|
||||||
|
"sources": "स्रोत",
|
||||||
|
"all_sources": "सभी स्रोत",
|
||||||
"close": "बंद करें",
|
"close": "बंद करें",
|
||||||
"cancel": "रद्द करें"
|
"cancel": "रद्द करें"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,8 @@
|
||||||
"upload_documents": "Enviar documentos",
|
"upload_documents": "Enviar documentos",
|
||||||
"create_shared_note": "Criar nota compartilhada",
|
"create_shared_note": "Criar nota compartilhada",
|
||||||
"processing_documents": "Processando documentos...",
|
"processing_documents": "Processando documentos...",
|
||||||
"active_tasks_count": "{count} tarefa(s) ativa(s)"
|
"delete_in_progress_warning": "{count} documento(s) estão pendentes ou em processamento e não podem ser excluídos.",
|
||||||
|
"delete_conflict_error": "{count} documento(s) começaram a ser processados. Tente novamente mais tarde."
|
||||||
},
|
},
|
||||||
"add_connector": {
|
"add_connector": {
|
||||||
"title": "Conecte suas ferramentas",
|
"title": "Conecte suas ferramentas",
|
||||||
|
|
@ -637,23 +638,6 @@
|
||||||
"add_first_config": "Adicionar primeira configuração",
|
"add_first_config": "Adicionar primeira configuração",
|
||||||
"created": "Criado"
|
"created": "Criado"
|
||||||
},
|
},
|
||||||
"breadcrumb": {
|
|
||||||
"dashboard": "Painel",
|
|
||||||
"search_space": "Espaço de pesquisa",
|
|
||||||
"chat": "Chat",
|
|
||||||
"documents": "Documentos",
|
|
||||||
"connectors": "Conectores",
|
|
||||||
"editor": "Editor",
|
|
||||||
"logs": "Logs",
|
|
||||||
"settings": "Configurações",
|
|
||||||
"upload_documents": "Enviar documentos",
|
|
||||||
"add_youtube": "Adicionar vídeos do YouTube",
|
|
||||||
"add_webpages": "Adicionar páginas web",
|
|
||||||
"add_connector": "Adicionar conector",
|
|
||||||
"manage_connectors": "Gerenciar conectores",
|
|
||||||
"edit_connector": "Editar conector",
|
|
||||||
"manage": "Gerenciar"
|
|
||||||
},
|
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"chats": "Chats privados",
|
"chats": "Chats privados",
|
||||||
"shared_chats": "Chats compartilhados",
|
"shared_chats": "Chats compartilhados",
|
||||||
|
|
@ -718,8 +702,11 @@
|
||||||
"filter": "Filtrar",
|
"filter": "Filtrar",
|
||||||
"all": "Tudo",
|
"all": "Tudo",
|
||||||
"unread": "Não lido",
|
"unread": "Não lido",
|
||||||
|
"errors_only": "Apenas erros",
|
||||||
"connectors": "Conectores",
|
"connectors": "Conectores",
|
||||||
"all_connectors": "Todos os conectores",
|
"all_connectors": "Todos os conectores",
|
||||||
|
"sources": "Fontes",
|
||||||
|
"all_sources": "Todas as fontes",
|
||||||
"close": "Fechar",
|
"close": "Fechar",
|
||||||
"cancel": "Cancelar"
|
"cancel": "Cancelar"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -316,7 +316,8 @@
|
||||||
"upload_documents": "上传文档",
|
"upload_documents": "上传文档",
|
||||||
"create_shared_note": "创建共享笔记",
|
"create_shared_note": "创建共享笔记",
|
||||||
"processing_documents": "正在处理文档...",
|
"processing_documents": "正在处理文档...",
|
||||||
"active_tasks_count": "{count} 个正在进行的工作项"
|
"delete_in_progress_warning": "{count} 个文档正在等待或处理中,无法删除。",
|
||||||
|
"delete_conflict_error": "{count} 个文档已开始处理,请稍后再试。"
|
||||||
},
|
},
|
||||||
"add_connector": {
|
"add_connector": {
|
||||||
"title": "连接您的工具",
|
"title": "连接您的工具",
|
||||||
|
|
@ -621,23 +622,6 @@
|
||||||
"add_first_config": "添加首个配置",
|
"add_first_config": "添加首个配置",
|
||||||
"created": "创建于"
|
"created": "创建于"
|
||||||
},
|
},
|
||||||
"breadcrumb": {
|
|
||||||
"dashboard": "仪表盘",
|
|
||||||
"search_space": "搜索空间",
|
|
||||||
"chat": "聊天",
|
|
||||||
"documents": "文档",
|
|
||||||
"connectors": "连接器",
|
|
||||||
"editor": "编辑器",
|
|
||||||
"logs": "日志",
|
|
||||||
"settings": "设置",
|
|
||||||
"upload_documents": "上传文档",
|
|
||||||
"add_youtube": "添加 YouTube 视频",
|
|
||||||
"add_webpages": "添加网页",
|
|
||||||
"add_connector": "添加连接器",
|
|
||||||
"manage_connectors": "管理连接器",
|
|
||||||
"edit_connector": "编辑连接器",
|
|
||||||
"manage": "管理"
|
|
||||||
},
|
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"chats": "私人对话",
|
"chats": "私人对话",
|
||||||
"shared_chats": "共享对话",
|
"shared_chats": "共享对话",
|
||||||
|
|
@ -702,8 +686,11 @@
|
||||||
"filter": "筛选",
|
"filter": "筛选",
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"unread": "未读",
|
"unread": "未读",
|
||||||
|
"errors_only": "仅错误",
|
||||||
"connectors": "连接器",
|
"connectors": "连接器",
|
||||||
"all_connectors": "所有连接器",
|
"all_connectors": "所有连接器",
|
||||||
|
"sources": "来源",
|
||||||
|
"all_sources": "所有来源",
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
"cancel": "取消"
|
"cancel": "取消"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.1.2",
|
"@biomejs/biome": "2.4.6",
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
|
|
|
||||||
74
surfsense_web/pnpm-lock.yaml
generated
74
surfsense_web/pnpm-lock.yaml
generated
|
|
@ -355,8 +355,8 @@ importers:
|
||||||
version: 5.0.11(@types/react@19.2.14)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
version: 5.0.11(@types/react@19.2.14)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.1.2
|
specifier: 2.4.6
|
||||||
version: 2.1.2
|
version: 2.4.6
|
||||||
'@eslint/eslintrc':
|
'@eslint/eslintrc':
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
version: 3.3.3
|
version: 3.3.3
|
||||||
|
|
@ -1080,55 +1080,55 @@ packages:
|
||||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@biomejs/biome@2.1.2':
|
'@biomejs/biome@2.4.6':
|
||||||
resolution: {integrity: sha512-yq8ZZuKuBVDgAS76LWCfFKHSYIAgqkxVB3mGVVpOe2vSkUTs7xG46zXZeNPRNVjiJuw0SZ3+J2rXiYx0RUpfGg==}
|
resolution: {integrity: sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@biomejs/cli-darwin-arm64@2.1.2':
|
'@biomejs/cli-darwin-arm64@2.4.6':
|
||||||
resolution: {integrity: sha512-leFAks64PEIjc7MY/cLjE8u5OcfBKkcDB0szxsWUB4aDfemBep1WVKt0qrEyqZBOW8LPHzrFMyDl3FhuuA0E7g==}
|
resolution: {integrity: sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@biomejs/cli-darwin-x64@2.1.2':
|
'@biomejs/cli-darwin-x64@2.4.6':
|
||||||
resolution: {integrity: sha512-Nmmv7wRX5Nj7lGmz0FjnWdflJg4zii8Ivruas6PBKzw5SJX/q+Zh2RfnO+bBnuKLXpj8kiI2x2X12otpH6a32A==}
|
resolution: {integrity: sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64-musl@2.1.2':
|
'@biomejs/cli-linux-arm64-musl@2.4.6':
|
||||||
resolution: {integrity: sha512-qgHvafhjH7Oca114FdOScmIKf1DlXT1LqbOrrbR30kQDLFPEOpBG0uzx6MhmsrmhGiCFCr2obDamu+czk+X0HQ==}
|
resolution: {integrity: sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64@2.1.2':
|
'@biomejs/cli-linux-arm64@2.4.6':
|
||||||
resolution: {integrity: sha512-NWNy2Diocav61HZiv2enTQykbPP/KrA/baS7JsLSojC7Xxh2nl9IczuvE5UID7+ksRy2e7yH7klm/WkA72G1dw==}
|
resolution: {integrity: sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64-musl@2.1.2':
|
'@biomejs/cli-linux-x64-musl@2.4.6':
|
||||||
resolution: {integrity: sha512-xlB3mU14ZUa3wzLtXfmk2IMOGL+S0aHFhSix/nssWS/2XlD27q+S6f0dlQ8WOCbYoXcuz8BCM7rCn2lxdTrlQA==}
|
resolution: {integrity: sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@2.1.2':
|
'@biomejs/cli-linux-x64@2.4.6':
|
||||||
resolution: {integrity: sha512-Km/UYeVowygTjpX6sGBzlizjakLoMQkxWbruVZSNE6osuSI63i4uCeIL+6q2AJlD3dxoiBJX70dn1enjQnQqwA==}
|
resolution: {integrity: sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@2.1.2':
|
'@biomejs/cli-win32-arm64@2.4.6':
|
||||||
resolution: {integrity: sha512-G8KWZli5ASOXA3yUQgx+M4pZRv3ND16h77UsdunUL17uYpcL/UC7RkWTdkfvMQvogVsAuz5JUcBDjgZHXxlKoA==}
|
resolution: {integrity: sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@biomejs/cli-win32-x64@2.1.2':
|
'@biomejs/cli-win32-x64@2.4.6':
|
||||||
resolution: {integrity: sha512-9zajnk59PMpjBkty3bK2IrjUsUHvqe9HWwyAWQBjGLE7MIBjbX2vwv1XPEhmO2RRuGoTkVx3WCanHrjAytICLA==}
|
resolution: {integrity: sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
@ -7971,39 +7971,39 @@ snapshots:
|
||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
'@biomejs/biome@2.1.2':
|
'@biomejs/biome@2.4.6':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@biomejs/cli-darwin-arm64': 2.1.2
|
'@biomejs/cli-darwin-arm64': 2.4.6
|
||||||
'@biomejs/cli-darwin-x64': 2.1.2
|
'@biomejs/cli-darwin-x64': 2.4.6
|
||||||
'@biomejs/cli-linux-arm64': 2.1.2
|
'@biomejs/cli-linux-arm64': 2.4.6
|
||||||
'@biomejs/cli-linux-arm64-musl': 2.1.2
|
'@biomejs/cli-linux-arm64-musl': 2.4.6
|
||||||
'@biomejs/cli-linux-x64': 2.1.2
|
'@biomejs/cli-linux-x64': 2.4.6
|
||||||
'@biomejs/cli-linux-x64-musl': 2.1.2
|
'@biomejs/cli-linux-x64-musl': 2.4.6
|
||||||
'@biomejs/cli-win32-arm64': 2.1.2
|
'@biomejs/cli-win32-arm64': 2.4.6
|
||||||
'@biomejs/cli-win32-x64': 2.1.2
|
'@biomejs/cli-win32-x64': 2.4.6
|
||||||
|
|
||||||
'@biomejs/cli-darwin-arm64@2.1.2':
|
'@biomejs/cli-darwin-arm64@2.4.6':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-darwin-x64@2.1.2':
|
'@biomejs/cli-darwin-x64@2.4.6':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64-musl@2.1.2':
|
'@biomejs/cli-linux-arm64-musl@2.4.6':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64@2.1.2':
|
'@biomejs/cli-linux-arm64@2.4.6':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64-musl@2.1.2':
|
'@biomejs/cli-linux-x64-musl@2.4.6':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@2.1.2':
|
'@biomejs/cli-linux-x64@2.4.6':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@2.1.2':
|
'@biomejs/cli-win32-arm64@2.4.6':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-win32-x64@2.1.2':
|
'@biomejs/cli-win32-x64@2.4.6':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@date-fns/tz@1.4.1': {}
|
'@date-fns/tz@1.4.1': {}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue