mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
Merge pull request #861 from AnishSarkar22/feat/document-revamp
feat: documents page revamp, new inbox logic & some improvements
This commit is contained in:
commit
176190fb2b
89 changed files with 3627 additions and 3951 deletions
|
|
@ -320,6 +320,8 @@ async def read_documents(
|
|||
page_size: int = 50,
|
||||
search_space_id: int | None = None,
|
||||
document_types: str | None = None,
|
||||
sort_by: str = "created_at",
|
||||
sort_order: str = "desc",
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
|
|
@ -392,6 +394,19 @@ async def read_documents(
|
|||
total_result = await session.execute(count_query)
|
||||
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
|
||||
offset = 0
|
||||
if skip is not None:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from typing import Literal
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
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 app.db import Notification, User, get_async_session
|
||||
|
|
@ -23,9 +23,26 @@ SYNC_WINDOW_DAYS = 14
|
|||
|
||||
# Valid notification types - must match frontend InboxItemTypeEnum
|
||||
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):
|
||||
"""Response model for a single notification."""
|
||||
|
|
@ -69,6 +86,21 @@ class MarkAllReadResponse(BaseModel):
|
|||
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):
|
||||
"""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
|
||||
|
||||
|
||||
@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)
|
||||
async def get_unread_count(
|
||||
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
||||
type_filter: NotificationType | None = Query(
|
||||
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),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> UnreadCountResponse:
|
||||
|
|
@ -116,6 +222,10 @@ async def get_unread_count(
|
|||
if 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_query = select(func.count(Notification.id)).where(*base_filter)
|
||||
total_result = await session.execute(total_query)
|
||||
|
|
@ -141,6 +251,17 @@ async def list_notifications(
|
|||
type_filter: NotificationType | None = Query(
|
||||
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(
|
||||
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)
|
||||
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)
|
||||
if before_date:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@ import {
|
|||
llmPreferencesAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-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 { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
||||
import { LayoutDataProvider } from "@/components/layout";
|
||||
import { OnboardingTour } from "@/components/onboarding-tour";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -27,8 +25,6 @@ export function DashboardClientLayout({
|
|||
}: {
|
||||
children: React.ReactNode;
|
||||
searchSpaceId: string;
|
||||
navSecondary?: any[];
|
||||
navMain?: any[];
|
||||
}) {
|
||||
const t = useTranslations("dashboard");
|
||||
const router = useRouter();
|
||||
|
|
@ -190,11 +186,7 @@ export function DashboardClientLayout({
|
|||
return (
|
||||
<DocumentUploadDialogProvider>
|
||||
<OnboardingTour />
|
||||
<LayoutDataProvider searchSpaceId={searchSpaceId} breadcrumb={<DashboardBreadcrumb />}>
|
||||
{children}
|
||||
</LayoutDataProvider>
|
||||
{/* Global connector dialog - triggered from documents page */}
|
||||
<ConnectorIndicator hideTrigger />
|
||||
<LayoutDataProvider searchSpaceId={searchSpaceId}>{children}</LayoutDataProvider>
|
||||
</DocumentUploadDialogProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useSetAtom } from "jotai";
|
||||
import {
|
||||
CircleAlert,
|
||||
FileType,
|
||||
ListFilter,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
Trash,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { ListFilter, Search, Upload, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -36,18 +13,14 @@ import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon";
|
|||
|
||||
export function DocumentsFilters({
|
||||
typeCounts: typeCountsRecord,
|
||||
selectedIds,
|
||||
onSearch,
|
||||
searchValue,
|
||||
onBulkDelete,
|
||||
onToggleType,
|
||||
activeTypes,
|
||||
}: {
|
||||
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
|
||||
selectedIds: Set<number>;
|
||||
onSearch: (v: string) => void;
|
||||
searchValue: string;
|
||||
onBulkDelete: () => Promise<void>;
|
||||
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
|
||||
activeTypes: DocumentTypeEnum[];
|
||||
}) {
|
||||
|
|
@ -55,11 +28,16 @@ export function DocumentsFilters({
|
|||
const id = React.useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Dialog hooks for action buttons
|
||||
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||
|
||||
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(() => {
|
||||
return Object.keys(typeCountsRecord).sort() as DocumentTypeEnum[];
|
||||
|
|
@ -80,235 +58,145 @@ export function DocumentsFilters({
|
|||
}, [typeCountsRecord]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-col gap-4 select-none"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.1 }}
|
||||
>
|
||||
{/* Main toolbar row */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Action Buttons - Left Side */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={openUploadDialog}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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"
|
||||
>
|
||||
<Upload size={16} />
|
||||
<span>Upload documents</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setConnectorDialogOpen(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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"
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
<span>Manage connectors</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex select-none">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{/* Type Filter */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
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"
|
||||
>
|
||||
<ListFilter size={14} />
|
||||
{activeTypes.length > 0 && (
|
||||
<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">
|
||||
{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-neutral-700">
|
||||
<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 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
<div
|
||||
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 */}
|
||||
<motion.div
|
||||
className="relative w-[180px]"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<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>
|
||||
<Input
|
||||
id={`${id}-input`}
|
||||
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}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder="Filter by title"
|
||||
placeholder="Search docs"
|
||||
type="text"
|
||||
aria-label={t("filter_placeholder")}
|
||||
/>
|
||||
{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"
|
||||
aria-label="Clear filter"
|
||||
onClick={() => {
|
||||
onSearch("");
|
||||
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" />
|
||||
</motion.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>
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -95,7 +95,6 @@ export function RowActions({
|
|||
{/* Desktop Actions */}
|
||||
<div className="hidden md:inline-flex items-center justify-center">
|
||||
{isEditable ? (
|
||||
// Editable documents: show 3-dot dropdown with edit + delete
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -123,9 +122,7 @@ export function RowActions({
|
|||
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
||||
disabled={isDeleteDisabled}
|
||||
className={
|
||||
isDeleteDisabled
|
||||
? "text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "text-destructive focus:text-destructive"
|
||||
isDeleteDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
|
|
@ -135,12 +132,11 @@ export function RowActions({
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
// Non-editable documents: show only delete button directly
|
||||
shouldShowDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
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)}
|
||||
disabled={isDeleting || isDeleteDisabled}
|
||||
>
|
||||
|
|
@ -154,7 +150,6 @@ export function RowActions({
|
|||
{/* Mobile Actions Dropdown */}
|
||||
<div className="inline-flex md:hidden items-center justify-center">
|
||||
{isEditable ? (
|
||||
// Editable documents: show 3-dot dropdown
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground">
|
||||
|
|
@ -178,9 +173,7 @@ export function RowActions({
|
|||
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
|
||||
disabled={isDeleteDisabled}
|
||||
className={
|
||||
isDeleteDisabled
|
||||
? "text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "text-destructive focus:text-destructive"
|
||||
isDeleteDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
|
|
@ -190,12 +183,11 @@ export function RowActions({
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
// Non-editable documents: show only delete button directly
|
||||
shouldShowDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
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)}
|
||||
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);
|
||||
toast.success("Note created successfully! Reindexing in background...");
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
} else {
|
||||
// Existing document — save
|
||||
const response = await authenticatedFetch(
|
||||
|
|
@ -277,7 +277,7 @@ export default function EditorPage() {
|
|||
|
||||
setHasUnsavedChanges(false);
|
||||
toast.success("Document saved! Reindexing in background...");
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving document:", error);
|
||||
|
|
@ -298,7 +298,7 @@ export default function EditorPage() {
|
|||
if (hasUnsavedChanges) {
|
||||
setShowUnsavedDialog(true);
|
||||
} else {
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -311,7 +311,7 @@ export default function EditorPage() {
|
|||
router.push(pendingNavigation);
|
||||
setPendingNavigation(null);
|
||||
} 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 }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Use React.use to unwrap the params Promise
|
||||
const { search_space_id } = use(params);
|
||||
|
||||
const customNavSecondary = [
|
||||
{
|
||||
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>
|
||||
);
|
||||
return <DashboardClientLayout searchSpaceId={search_space_id}>{children}</DashboardClientLayout>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1222,7 +1222,6 @@ function LogRowActions({ row, t }: { row: Row<Log>; t: (key: string) => string }
|
|||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
mentionedDocumentIdsAtom,
|
||||
mentionedDocumentsAtom,
|
||||
messageDocumentsMapAtom,
|
||||
sidebarSelectedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import {
|
||||
clearPlanOwnerRegistry,
|
||||
|
|
@ -31,7 +32,6 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
|||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||
import { ReportPanel } from "@/components/report-panel/report-panel";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
||||
|
|
@ -180,11 +180,12 @@ export default function NewChatPage() {
|
|||
interruptData: Record<string, unknown>;
|
||||
} | 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 mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
||||
const sidebarDocuments = useAtomValue(sidebarSelectedDocumentsAtom);
|
||||
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
|
||||
const setSidebarDocuments = useSetAtom(sidebarSelectedDocumentsAtom);
|
||||
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
||||
const setCurrentThreadState = useSetAtom(currentThreadAtom);
|
||||
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
|
||||
|
|
@ -276,11 +277,8 @@ export default function NewChatPage() {
|
|||
setThreadId(null);
|
||||
setCurrentThread(null);
|
||||
setMessageThinkingSteps(new Map());
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: [],
|
||||
document_ids: [],
|
||||
});
|
||||
setMentionedDocuments([]);
|
||||
setSidebarDocuments([]);
|
||||
setMessageDocumentsMap({});
|
||||
clearPlanOwnerRegistry(); // Reset plan ownership for new chat
|
||||
closeReportPanel(); // Close report panel when switching chats
|
||||
|
|
@ -345,8 +343,8 @@ export default function NewChatPage() {
|
|||
}, [
|
||||
urlChatId,
|
||||
setMessageDocumentsMap,
|
||||
setMentionedDocumentIds,
|
||||
setMentionedDocuments,
|
||||
setSidebarDocuments,
|
||||
closeReportPanel,
|
||||
]);
|
||||
|
||||
|
|
@ -473,7 +471,7 @@ export default function NewChatPage() {
|
|||
const newThread = await createThread(searchSpaceId, initialTitle);
|
||||
currentThreadId = newThread.id;
|
||||
setThreadId(currentThreadId);
|
||||
// Set currentThread so ChatHeader can show share button immediately
|
||||
// Set currentThread so share button in header appears immediately
|
||||
setCurrentThread(newThread);
|
||||
|
||||
// Track chat creation
|
||||
|
|
@ -528,31 +526,30 @@ export default function NewChatPage() {
|
|||
messageLength: userQuery.length,
|
||||
});
|
||||
|
||||
// Store mentioned documents with this message for display
|
||||
if (mentionedDocuments.length > 0) {
|
||||
const docsInfo: MentionedDocumentInfo[] = mentionedDocuments.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: doc.document_type,
|
||||
}));
|
||||
// Combine @-mention chips + sidebar selections for display & persistence
|
||||
const allMentionedDocs: MentionedDocumentInfo[] = [];
|
||||
const seenDocKeys = new Set<string>();
|
||||
for (const doc of [...mentionedDocuments, ...sidebarDocuments]) {
|
||||
const key = `${doc.document_type}:${doc.id}`;
|
||||
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) => ({
|
||||
...prev,
|
||||
[userMsgId]: docsInfo,
|
||||
[userMsgId]: allMentionedDocs,
|
||||
}));
|
||||
}
|
||||
|
||||
// Persist user message with mentioned documents (don't await, fire and forget)
|
||||
const persistContent: unknown[] = [...message.content];
|
||||
|
||||
// Add mentioned documents for persistence
|
||||
if (mentionedDocuments.length > 0) {
|
||||
if (allMentionedDocs.length > 0) {
|
||||
persistContent.push({
|
||||
type: "mentioned-documents",
|
||||
documents: mentionedDocuments.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: doc.document_type,
|
||||
})),
|
||||
documents: allMentionedDocs,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -560,7 +557,17 @@ export default function NewChatPage() {
|
|||
role: "user",
|
||||
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) {
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
|
||||
}
|
||||
|
|
@ -618,11 +625,8 @@ export default function NewChatPage() {
|
|||
|
||||
// Clear mentioned documents after capturing them
|
||||
if (hasDocumentIds || hasSurfsenseDocIds) {
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: [],
|
||||
document_ids: [],
|
||||
});
|
||||
setMentionedDocuments([]);
|
||||
setSidebarDocuments([]);
|
||||
}
|
||||
|
||||
const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
|
||||
|
|
@ -747,15 +751,6 @@ export default function NewChatPage() {
|
|||
queryClient.invalidateQueries({
|
||||
queryKey: ["threads", String(searchSpaceId)],
|
||||
});
|
||||
// Invalidate thread detail for breadcrumb update
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
"threads",
|
||||
String(searchSpaceId),
|
||||
"detail",
|
||||
String(titleData.threadId),
|
||||
],
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -920,8 +915,9 @@ export default function NewChatPage() {
|
|||
messages,
|
||||
mentionedDocumentIds,
|
||||
mentionedDocuments,
|
||||
setMentionedDocumentIds,
|
||||
sidebarDocuments,
|
||||
setMentionedDocuments,
|
||||
setSidebarDocuments,
|
||||
setMessageDocumentsMap,
|
||||
queryClient,
|
||||
currentThread,
|
||||
|
|
@ -1674,10 +1670,7 @@ export default function NewChatPage() {
|
|||
{/* <WriteTodosToolUI /> Disabled for now */}
|
||||
<div className="flex h-[calc(100dvh-64px)] overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<Thread
|
||||
messageThinkingSteps={messageThinkingSteps}
|
||||
header={<ChatHeader searchSpaceId={searchSpaceId} />}
|
||||
/>
|
||||
<Thread messageThinkingSteps={messageThinkingSteps} />
|
||||
</div>
|
||||
<ReportPanel />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -564,7 +564,7 @@ function MemberRow({
|
|||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
className="min-w-[120px] bg-muted dark:border dark:border-neutral-700"
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{canManageRoles &&
|
||||
roles
|
||||
|
|
@ -581,8 +581,8 @@ function MemberRow({
|
|||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -607,7 +607,7 @@ function MemberRow({
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`)
|
||||
|
|
@ -811,7 +811,7 @@ function CreateInviteDialog({
|
|||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
"w-full justify-start text-left font-normal bg-transparent",
|
||||
!expiresAt && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
|
|
@ -833,7 +833,7 @@ function CreateInviteDialog({
|
|||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ button {
|
|||
}
|
||||
}
|
||||
|
||||
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
|
||||
@source '../node_modules/streamdown/dist/*.js';
|
||||
@source '../node_modules/@streamdown/code/dist/*.js';
|
||||
@source '../node_modules/@streamdown/math/dist/*.js';
|
||||
@source "../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}";
|
||||
@source "../node_modules/streamdown/dist/*.js";
|
||||
@source "../node_modules/@streamdown/code/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(
|
||||
/\/+$/,
|
||||
|
|
|
|||
|
|
@ -1,26 +1,47 @@
|
|||
"use client";
|
||||
|
||||
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.
|
||||
* This is used to pass document context to the backend when sending a message.
|
||||
*/
|
||||
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.
|
||||
* Atom to store the full document objects mentioned via @-mention chips
|
||||
* in the current chat composer. This persists across component remounts.
|
||||
*/
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ export const uploadDocumentMutationAtom = atomWithMutation((get) => {
|
|||
|
||||
onSuccess: () => {
|
||||
// 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({
|
||||
queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,3 +5,5 @@ export const globalDocumentsQueryParamsAtom = atom<GetDocumentsRequest["queryPar
|
|||
page_size: 10,
|
||||
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": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"experimentalScannerIgnores": ["node_modules", ".git", ".next", "dist", "build", "coverage"],
|
||||
"includes": ["**", "!!node_modules", "!!.git", "!!.next", "!!dist", "!!build", "!!coverage"],
|
||||
"maxSize": 1048576
|
||||
},
|
||||
"formatter": {
|
||||
|
|
@ -65,6 +65,9 @@
|
|||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Bell,
|
||||
ExternalLink,
|
||||
Info,
|
||||
type LucideIcon,
|
||||
Rocket,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Bell, ExternalLink, Info, type LucideIcon, Rocket, Wrench, Zap } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -114,4 +106,3 @@ export function AnnouncementCard({ announcement }: { announcement: AnnouncementW
|
|||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,4 +15,3 @@ export function AnnouncementsEmptyState() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useAtomValue } from "jotai";
|
|||
import { AlertTriangle, Cable, Settings } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
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 {
|
||||
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 { 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 searchParams = useSearchParams();
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
|
|
@ -66,11 +66,15 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
|||
const { data: documentTypeCounts, isFetching: documentTypesLoading } =
|
||||
useAtomValue(documentTypeCountsAtom);
|
||||
|
||||
// Fetch notifications to detect indexing failures
|
||||
const { inboxItems = [] } = useInbox(
|
||||
// Fetch status notifications to detect indexing failures
|
||||
const { inboxItems: statusInboxItems = [] } = useInbox(
|
||||
currentUser?.id ?? 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
|
||||
|
|
@ -189,40 +193,36 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
{!hideTrigger && (
|
||||
<TooltipIconButton
|
||||
data-joyride="connector-icon"
|
||||
tooltip={
|
||||
hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"
|
||||
}
|
||||
side="bottom"
|
||||
className={cn(
|
||||
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
||||
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
|
||||
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
|
||||
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
|
||||
)}
|
||||
aria-label={
|
||||
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
|
||||
}
|
||||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
{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">
|
||||
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
<TooltipIconButton
|
||||
data-joyride="connector-icon"
|
||||
tooltip={hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"}
|
||||
side="bottom"
|
||||
className={cn(
|
||||
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
||||
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
|
||||
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
|
||||
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
|
||||
)}
|
||||
aria-label={
|
||||
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
|
||||
}
|
||||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
{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">
|
||||
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
{isYouTubeView && searchSpaceId ? (
|
||||
|
|
@ -415,7 +415,6 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
|||
activeDocumentTypes={activeDocumentTypes}
|
||||
connectors={connectors as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onTabChange={handleTabChange}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowRight, Cable } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Cable } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
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 { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants";
|
||||
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
||||
|
|
@ -25,37 +20,21 @@ interface ActiveConnectorsTabProps {
|
|||
activeDocumentTypes: Array<[string, number]>;
|
||||
connectors: SearchSourceConnector[];
|
||||
indexingConnectorIds: Set<number>;
|
||||
searchSpaceId: string;
|
||||
onTabChange: (value: string) => void;
|
||||
onManage?: (connector: SearchSourceConnector) => 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> = ({
|
||||
searchQuery,
|
||||
hasSources,
|
||||
activeDocumentTypes,
|
||||
connectors,
|
||||
indexingConnectorIds,
|
||||
searchSpaceId,
|
||||
onTabChange,
|
||||
onTabChange: _onTabChange,
|
||||
onManage,
|
||||
onViewAccountsList,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const handleViewAllDocuments = () => {
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
};
|
||||
|
||||
// Convert activeDocumentTypes array to Record for utility function
|
||||
const documentTypeCounts = activeDocumentTypes.reduce(
|
||||
(acc, [docType, count]) => {
|
||||
|
|
@ -300,15 +279,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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 className="flex flex-wrap items-center gap-2">
|
||||
{standaloneDocuments.map((doc) => (
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { TagInput, type Tag as TagType } from "emblor";
|
||||
import { useAtom } from "jotai";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type FC, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -24,7 +23,6 @@ interface YouTubeCrawlerViewProps {
|
|||
|
||||
export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId, onBack }) => {
|
||||
const t = useTranslations("add_youtube");
|
||||
const router = useRouter();
|
||||
const [videoTags, setVideoTags] = useState<TagType[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -74,9 +72,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
toast(t("success_toast"), {
|
||||
description: t("success_toast_desc"),
|
||||
});
|
||||
// Close the popup and navigate to documents
|
||||
onBack();
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = error instanceof Error ? error.message : t("error_generic");
|
||||
|
|
|
|||
|
|
@ -120,7 +120,12 @@ const DocumentUploadPopupContent: FC<{
|
|||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{/* Scrollable container for mobile */}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export interface InlineMentionEditorRef {
|
|||
getText: () => string;
|
||||
getMentionedDocuments: () => MentionedDocument[];
|
||||
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
||||
removeDocumentChip: (docId: number, docType?: string) => void;
|
||||
setDocumentChipStatus: (
|
||||
docId: number,
|
||||
docType: string | undefined,
|
||||
|
|
@ -175,33 +176,27 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
|
||||
chip.contentEditable = "false";
|
||||
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.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");
|
||||
iconSpan.className = "shrink-0 flex items-center text-muted-foreground";
|
||||
iconSpan.className = "flex items-center text-muted-foreground";
|
||||
iconSpan.innerHTML = ReactDOMServer.renderToString(
|
||||
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");
|
||||
removeBtn.type = "button";
|
||||
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(
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -213,15 +208,45 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
next.delete(docKey);
|
||||
return next;
|
||||
});
|
||||
// Notify parent that a document was removed
|
||||
onDocumentRemove?.(doc.id, doc.document_type);
|
||||
focusAtEnd();
|
||||
};
|
||||
|
||||
chip.appendChild(iconSpan);
|
||||
chip.appendChild(titleSpan);
|
||||
chip.appendChild(statusSpan);
|
||||
chip.appendChild(removeBtn);
|
||||
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 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;
|
||||
},
|
||||
|
|
@ -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
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => editorRef.current?.focus(),
|
||||
|
|
@ -395,6 +446,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
getText,
|
||||
getMentionedDocuments,
|
||||
insertDocumentChip,
|
||||
removeDocumentChip,
|
||||
setDocumentChipStatus,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ function ThreadListItemComponent({
|
|||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
||||
<DropdownMenuItem onClick={onDelete}>
|
||||
<TrashIcon className="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -18,23 +18,21 @@ import {
|
|||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
Dot,
|
||||
DownloadIcon,
|
||||
FileWarning,
|
||||
Paperclip,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SquareIcon,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import {
|
||||
mentionedDocumentIdsAtom,
|
||||
mentionedDocumentsAtom,
|
||||
sidebarSelectedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
|
|
@ -45,6 +43,7 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
import {
|
||||
InlineMentionEditor,
|
||||
type InlineMentionEditorRef,
|
||||
|
|
@ -63,11 +62,9 @@ import {
|
|||
} from "@/components/new-chat/document-mention-picker";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** 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.",
|
||||
];
|
||||
|
||||
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 {
|
||||
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 (
|
||||
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
|
||||
<ThreadContent header={header} />
|
||||
<ThreadContent />
|
||||
</ThinkingStepsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
||||
const ThreadContent: FC = () => {
|
||||
const showGutter = useAtomValue(showCommentsGutterAtom);
|
||||
|
||||
return (
|
||||
|
|
@ -128,8 +107,6 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
|||
showGutter && "lg:pr-30"
|
||||
)}
|
||||
>
|
||||
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
|
||||
|
||||
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
||||
<ThreadWelcome />
|
||||
</AssistantIf>
|
||||
|
|
@ -250,19 +227,13 @@ const ThreadWelcome: FC = () => {
|
|||
const Composer: FC = () => {
|
||||
// Document mention state (atoms persist across component remounts)
|
||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||
const setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom);
|
||||
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const [uploadedMentionDocs, setUploadedMentionDocs] = useState<
|
||||
Record<number, UploadedMentionDoc>
|
||||
>({});
|
||||
const [isUploadingDocs, setIsUploadingDocs] = useState(false);
|
||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const isFileDialogOpenRef = useRef(false);
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
const { search_space_id, chat_id } = useParams();
|
||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
||||
const composerRuntime = useComposerRuntime();
|
||||
const hasAutoFocusedRef = useRef(false);
|
||||
|
||||
|
|
@ -317,7 +288,7 @@ const Composer: FC = () => {
|
|||
const assistantIdsKey = useAssistantState(({ thread }) =>
|
||||
thread.messages
|
||||
.filter((m) => m.role === "assistant" && m.id?.startsWith("msg-"))
|
||||
.map((m) => m.id!.replace("msg-", ""))
|
||||
.map((m) => m.id?.replace("msg-", ""))
|
||||
.join(",")
|
||||
);
|
||||
const assistantDbMessageIds = useMemo(
|
||||
|
|
@ -337,18 +308,6 @@ const Composer: FC = () => {
|
|||
}
|
||||
}, [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
|
||||
const handleEditorChange = useCallback(
|
||||
(text: string) => {
|
||||
|
|
@ -401,75 +360,35 @@ const Composer: FC = () => {
|
|||
[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)
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (
|
||||
isThreadRunning ||
|
||||
isBlockedByOtherUser ||
|
||||
isUploadingDocs ||
|
||||
blockingUploadedMentions.length > 0
|
||||
) {
|
||||
if (isThreadRunning || isBlockedByOtherUser) {
|
||||
return;
|
||||
}
|
||||
if (!showDocumentPopover) {
|
||||
composerRuntime.send();
|
||||
editorRef.current?.clear();
|
||||
setMentionedDocuments([]);
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: [],
|
||||
document_ids: [],
|
||||
});
|
||||
setSidebarDocs([]);
|
||||
}
|
||||
}, [
|
||||
showDocumentPopover,
|
||||
isThreadRunning,
|
||||
isBlockedByOtherUser,
|
||||
isUploadingDocs,
|
||||
blockingUploadedMentions.length,
|
||||
composerRuntime,
|
||||
setMentionedDocuments,
|
||||
setMentionedDocumentIds,
|
||||
setSidebarDocs,
|
||||
]);
|
||||
|
||||
// Remove document from mentions and sync IDs to atom
|
||||
const handleDocumentRemove = useCallback(
|
||||
(docId: number, docType?: string) => {
|
||||
setMentionedDocuments((prev) => {
|
||||
const updated = 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((prev) =>
|
||||
prev.filter((doc) => !(doc.id === docId && doc.document_type === docType))
|
||||
);
|
||||
},
|
||||
[setMentionedDocuments, setMentionedDocumentIds]
|
||||
[setMentionedDocuments]
|
||||
);
|
||||
|
||||
// Add selected documents from picker, insert chips, and sync IDs to atom
|
||||
const handleDocumentsMention = useCallback(
|
||||
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
||||
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
|
||||
|
|
@ -486,185 +405,14 @@ const Composer: FC = () => {
|
|||
const uniqueNewDocs = documents.filter(
|
||||
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
|
||||
);
|
||||
const updated = [...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;
|
||||
return [...prev, ...uniqueNewDocs];
|
||||
});
|
||||
|
||||
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 (
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
||||
<ChatSessionStatus
|
||||
|
|
@ -688,15 +436,6 @@ const Composer: FC = () => {
|
|||
className="min-h-[24px]"
|
||||
/>
|
||||
</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) */}
|
||||
{showDocumentPopover &&
|
||||
typeof document !== "undefined" &&
|
||||
|
|
@ -722,15 +461,7 @@ const Composer: FC = () => {
|
|||
/>,
|
||||
document.body
|
||||
)}
|
||||
<ComposerAction
|
||||
isBlockedByOtherUser={isBlockedByOtherUser}
|
||||
onUploadClick={handleUploadClick}
|
||||
isUploadingDocs={isUploadingDocs}
|
||||
blockingUploadedMentionsCount={blockingUploadedMentions.length}
|
||||
hasFailedUploadedMentions={blockingUploadedMentions.some(
|
||||
(doc) => uploadedMentionDocs[doc.id]?.state === "failed"
|
||||
)}
|
||||
/>
|
||||
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
|
|
@ -738,29 +469,20 @@ const Composer: FC = () => {
|
|||
|
||||
interface ComposerActionProps {
|
||||
isBlockedByOtherUser?: boolean;
|
||||
onUploadClick: () => void;
|
||||
isUploadingDocs: boolean;
|
||||
blockingUploadedMentionsCount: number;
|
||||
hasFailedUploadedMentions: boolean;
|
||||
}
|
||||
|
||||
const ComposerAction: FC<ComposerActionProps> = ({
|
||||
isBlockedByOtherUser = false,
|
||||
onUploadClick,
|
||||
isUploadingDocs,
|
||||
blockingUploadedMentionsCount,
|
||||
hasFailedUploadedMentions,
|
||||
}) => {
|
||||
const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
|
||||
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 text = composer.text?.trim() || "";
|
||||
return text.length === 0;
|
||||
});
|
||||
const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0;
|
||||
|
||||
// Check if a model is configured
|
||||
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
||||
const { data: preferences } = useAtomValue(llmPreferencesAtom);
|
||||
|
|
@ -770,121 +492,91 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
const agentLlmId = preferences.agent_llm_id;
|
||||
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) {
|
||||
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}
|
||||
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}, [preferences, globalConfigs, userConfigs]);
|
||||
|
||||
const isSendDisabled =
|
||||
isComposerEmpty ||
|
||||
!hasModelConfigured ||
|
||||
isBlockedByOtherUser ||
|
||||
isUploadingDocs ||
|
||||
blockingUploadedMentionsCount > 0;
|
||||
const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
|
||||
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipIconButton
|
||||
tooltip={
|
||||
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>
|
||||
)
|
||||
}
|
||||
tooltip="Upload"
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
||||
aria-label="Upload files"
|
||||
onClick={onUploadClick}
|
||||
disabled={isUploadingDocs}
|
||||
aria-label="Upload documents"
|
||||
onClick={openUploadDialog}
|
||||
>
|
||||
{isUploadingDocs ? (
|
||||
<Spinner size="sm" className="text-muted-foreground" />
|
||||
) : (
|
||||
<Paperclip className="size-4" />
|
||||
)}
|
||||
<PlusIcon className="size-4" />
|
||||
</TooltipIconButton>
|
||||
<ConnectorIndicator />
|
||||
</div>
|
||||
|
||||
{blockingUploadedMentionsCount > 0 && (
|
||||
<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 && (
|
||||
{!hasModelConfigured && (
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
||||
<AlertCircle className="size-3" />
|
||||
<span>Select a model</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
||||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||
<TooltipIconButton
|
||||
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
|
||||
<div className="flex items-center gap-2">
|
||||
{sidebarDocs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="aui-composer-cancel size-8 rounded-full"
|
||||
aria-label="Stop generating"
|
||||
onClick={() => setDocumentsSidebarOpen(true)}
|
||||
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"
|
||||
>
|
||||
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
||||
</Button>
|
||||
</ComposerPrimitive.Cancel>
|
||||
</AssistantIf>
|
||||
{sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
|
||||
</button>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
|
|||
)}
|
||||
{canEdit && canDelete && <DropdownMenuSeparator />}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
||||
<DropdownMenuItem onClick={onDelete}>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</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";
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -76,7 +76,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa
|
|||
<SelectTrigger id="param-key" className="w-full">
|
||||
<SelectValue placeholder="Select parameter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="bg-muted dark:border-neutral-700">
|
||||
{PARAM_KEYS.map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{key}
|
||||
|
|
@ -104,7 +104,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa
|
|||
onClick={handleAdd}
|
||||
disabled={!selectedKey || value === ""}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" /> Add Parameter
|
||||
Add Parameter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Inbox,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
PencilIcon,
|
||||
SquareLibrary,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 { searchSpacesAtom } from "@/atoms/search-spaces/search-space-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 {
|
||||
Dialog,
|
||||
|
|
@ -32,6 +35,7 @@ import {
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { logout } from "@/lib/auth-utils";
|
||||
|
|
@ -46,7 +50,6 @@ import { LayoutShell } from "../ui/shell";
|
|||
interface LayoutDataProviderProps {
|
||||
searchSpaceId: string;
|
||||
children: React.ReactNode;
|
||||
breadcrumb?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -60,11 +63,7 @@ function formatInboxCount(count: number): string {
|
|||
return `${thousands}k+`;
|
||||
}
|
||||
|
||||
export function LayoutDataProvider({
|
||||
searchSpaceId,
|
||||
children,
|
||||
breadcrumb,
|
||||
}: LayoutDataProviderProps) {
|
||||
export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProviderProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
|
|
@ -114,40 +113,27 @@ export function LayoutDataProvider({
|
|||
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = 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
|
||||
const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false);
|
||||
|
||||
// Search space dialog state
|
||||
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
||||
|
||||
// Inbox hooks - separate data sources for mentions and status tabs
|
||||
// This ensures each tab has independent pagination and data loading
|
||||
// Per-tab inbox hooks — each has independent API loading, pagination,
|
||||
// and Electric live queries. The Electric sync shape is shared (client-level cache).
|
||||
const userId = user?.id ? String(user.id) : null;
|
||||
const numericSpaceId = Number(searchSpaceId) || null;
|
||||
|
||||
const {
|
||||
inboxItems: mentionItems,
|
||||
unreadCount: mentionUnreadCount,
|
||||
loading: mentionLoading,
|
||||
loadingMore: mentionLoadingMore,
|
||||
hasMore: mentionHasMore,
|
||||
loadMore: mentionLoadMore,
|
||||
markAsRead: markMentionAsRead,
|
||||
markAllAsRead: markAllMentionsAsRead,
|
||||
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
|
||||
const commentsInbox = useInbox(userId, numericSpaceId, "comments");
|
||||
const statusInbox = useInbox(userId, numericSpaceId, "status");
|
||||
|
||||
const {
|
||||
inboxItems: statusItems,
|
||||
unreadCount: allUnreadCount,
|
||||
loading: statusLoading,
|
||||
loadingMore: statusLoadingMore,
|
||||
hasMore: statusHasMore,
|
||||
loadMore: statusLoadMore,
|
||||
markAsRead: markStatusAsRead,
|
||||
markAllAsRead: markAllStatusAsRead,
|
||||
} = useInbox(userId, Number(searchSpaceId) || null, null);
|
||||
const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount;
|
||||
|
||||
const totalUnreadCount = allUnreadCount;
|
||||
const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount);
|
||||
// Whether any documents are currently being uploaded/indexed — drives sidebar spinner
|
||||
const isDocumentsProcessing = useDocumentsProcessing(numericSpaceId);
|
||||
|
||||
// Track seen notification IDs to detect new page_limit_exceeded notifications
|
||||
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
||||
|
|
@ -155,14 +141,12 @@ export function LayoutDataProvider({
|
|||
|
||||
// Effect to show toast for new page_limit_exceeded notifications
|
||||
useEffect(() => {
|
||||
if (statusLoading) return;
|
||||
if (statusInbox.loading) return;
|
||||
|
||||
// Get page_limit_exceeded notifications
|
||||
const pageLimitNotifications = statusItems.filter(
|
||||
const pageLimitNotifications = statusInbox.inboxItems.filter(
|
||||
(item) => item.type === "page_limit_exceeded"
|
||||
);
|
||||
|
||||
// On initial load, just mark all as seen without showing toasts
|
||||
if (isInitialLoad.current) {
|
||||
for (const notification of pageLimitNotifications) {
|
||||
seenPageLimitNotifications.current.add(notification.id);
|
||||
|
|
@ -171,16 +155,13 @@ export function LayoutDataProvider({
|
|||
return;
|
||||
}
|
||||
|
||||
// Find new notifications (not yet seen)
|
||||
const newNotifications = pageLimitNotifications.filter(
|
||||
(notification) => !seenPageLimitNotifications.current.has(notification.id)
|
||||
);
|
||||
|
||||
// Show toast for each new page_limit_exceeded notification
|
||||
for (const notification of newNotifications) {
|
||||
seenPageLimitNotifications.current.add(notification.id);
|
||||
|
||||
// Extract metadata for navigation
|
||||
const actionUrl = isPageLimitExceededMetadata(notification.metadata)
|
||||
? notification.metadata.action_url
|
||||
: `/dashboard/${searchSpaceId}/more-pages`;
|
||||
|
|
@ -195,24 +176,7 @@ export function LayoutDataProvider({
|
|||
},
|
||||
});
|
||||
}
|
||||
}, [statusItems, statusLoading, 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]);
|
||||
}, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]);
|
||||
|
||||
// Delete dialogs state
|
||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||
|
|
@ -295,34 +259,35 @@ export function LayoutDataProvider({
|
|||
// Navigation items
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Documents",
|
||||
url: `/dashboard/${searchSpaceId}/documents`,
|
||||
icon: SquareLibrary,
|
||||
isActive: pathname?.includes("/documents"),
|
||||
},
|
||||
{
|
||||
title: "Inbox",
|
||||
url: "#inbox", // Special URL to indicate this is handled differently
|
||||
url: "#inbox",
|
||||
icon: Inbox,
|
||||
isActive: isInboxSidebarOpen,
|
||||
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
|
||||
},
|
||||
{
|
||||
title: "Documents",
|
||||
url: "#documents",
|
||||
icon: SquareLibrary,
|
||||
isActive: isDocumentsSidebarOpen,
|
||||
showSpinner: isDocumentsProcessing,
|
||||
},
|
||||
{
|
||||
title: "Announcements",
|
||||
url: "#announcements", // Special URL to indicate this is handled differently
|
||||
url: "#announcements",
|
||||
icon: Megaphone,
|
||||
isActive: isAnnouncementsSidebarOpen,
|
||||
badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
searchSpaceId,
|
||||
pathname,
|
||||
isInboxSidebarOpen,
|
||||
isDocumentsSidebarOpen,
|
||||
totalUnreadCount,
|
||||
isAnnouncementsSidebarOpen,
|
||||
announcementUnreadCount,
|
||||
isDocumentsProcessing,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -415,10 +380,22 @@ export function LayoutDataProvider({
|
|||
|
||||
const handleNavItemClick = useCallback(
|
||||
(item: NavItem) => {
|
||||
// Handle inbox specially - toggle sidebar instead of navigating
|
||||
if (item.url === "#inbox") {
|
||||
setIsInboxSidebarOpen((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);
|
||||
setIsAllPrivateChatsSidebarOpen(false);
|
||||
setIsAnnouncementsSidebarOpen(false);
|
||||
|
|
@ -427,13 +404,13 @@ export function LayoutDataProvider({
|
|||
});
|
||||
return;
|
||||
}
|
||||
// Handle announcements specially - toggle sidebar instead of navigating
|
||||
if (item.url === "#announcements") {
|
||||
setIsAnnouncementsSidebarOpen((prev) => {
|
||||
if (!prev) {
|
||||
setIsInboxSidebarOpen(false);
|
||||
setIsAllSharedChatsSidebarOpen(false);
|
||||
setIsAllPrivateChatsSidebarOpen(false);
|
||||
setIsDocumentsSidebarOpen(false);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
|
|
@ -441,13 +418,7 @@ export function LayoutDataProvider({
|
|||
}
|
||||
router.push(item.url);
|
||||
},
|
||||
[
|
||||
router,
|
||||
setIsAllPrivateChatsSidebarOpen,
|
||||
setIsAllSharedChatsSidebarOpen,
|
||||
setIsAnnouncementsSidebarOpen,
|
||||
setIsInboxSidebarOpen,
|
||||
]
|
||||
[router, setIsDocumentsSidebarOpen]
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
|
|
@ -544,15 +515,17 @@ export function LayoutDataProvider({
|
|||
setIsAllSharedChatsSidebarOpen(true);
|
||||
setIsAllPrivateChatsSidebarOpen(false);
|
||||
setIsInboxSidebarOpen(false);
|
||||
setIsDocumentsSidebarOpen(false);
|
||||
setIsAnnouncementsSidebarOpen(false);
|
||||
}, []);
|
||||
}, [setIsDocumentsSidebarOpen]);
|
||||
|
||||
const handleViewAllPrivateChats = useCallback(() => {
|
||||
setIsAllPrivateChatsSidebarOpen(true);
|
||||
setIsAllSharedChatsSidebarOpen(false);
|
||||
setIsInboxSidebarOpen(false);
|
||||
setIsDocumentsSidebarOpen(false);
|
||||
setIsAnnouncementsSidebarOpen(false);
|
||||
}, []);
|
||||
}, [setIsDocumentsSidebarOpen]);
|
||||
|
||||
// Delete handlers
|
||||
const confirmDeleteChat = useCallback(async () => {
|
||||
|
|
@ -583,10 +556,6 @@ export function LayoutDataProvider({
|
|||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["all-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) {
|
||||
console.error("Error renaming thread:", error);
|
||||
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
|
||||
|
|
@ -641,7 +610,6 @@ export function LayoutDataProvider({
|
|||
onUserSettings={handleUserSettings}
|
||||
onLogout={handleLogout}
|
||||
pageUsage={pageUsage}
|
||||
breadcrumb={breadcrumb}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
isChatPage={isChatPage}
|
||||
|
|
@ -649,26 +617,27 @@ export function LayoutDataProvider({
|
|||
inbox={{
|
||||
isOpen: isInboxSidebarOpen,
|
||||
onOpenChange: setIsInboxSidebarOpen,
|
||||
// Separate data sources for each tab
|
||||
mentions: {
|
||||
items: mentionItems,
|
||||
unreadCount: mentionUnreadCount,
|
||||
loading: mentionLoading,
|
||||
loadingMore: mentionLoadingMore,
|
||||
hasMore: mentionHasMore,
|
||||
loadMore: mentionLoadMore,
|
||||
totalUnreadCount,
|
||||
comments: {
|
||||
items: commentsInbox.inboxItems,
|
||||
unreadCount: commentsInbox.unreadCount,
|
||||
loading: commentsInbox.loading,
|
||||
loadingMore: commentsInbox.loadingMore,
|
||||
hasMore: commentsInbox.hasMore,
|
||||
loadMore: commentsInbox.loadMore,
|
||||
markAsRead: commentsInbox.markAsRead,
|
||||
markAllAsRead: commentsInbox.markAllAsRead,
|
||||
},
|
||||
status: {
|
||||
items: statusItems,
|
||||
unreadCount: statusOnlyUnreadCount,
|
||||
loading: statusLoading,
|
||||
loadingMore: statusLoadingMore,
|
||||
hasMore: statusHasMore,
|
||||
loadMore: statusLoadMore,
|
||||
items: statusInbox.inboxItems,
|
||||
unreadCount: statusInbox.unreadCount,
|
||||
loading: statusInbox.loading,
|
||||
loadingMore: statusInbox.loadingMore,
|
||||
hasMore: statusInbox.hasMore,
|
||||
loadMore: statusInbox.loadMore,
|
||||
markAsRead: statusInbox.markAsRead,
|
||||
markAllAsRead: statusInbox.markAllAsRead,
|
||||
},
|
||||
totalUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
isDocked: isInboxDocked,
|
||||
onDockedChange: setIsInboxDocked,
|
||||
}}
|
||||
|
|
@ -686,36 +655,33 @@ export function LayoutDataProvider({
|
|||
onOpenChange: setIsAllPrivateChatsSidebarOpen,
|
||||
searchSpaceId,
|
||||
}}
|
||||
documentsPanel={{
|
||||
open: isDocumentsSidebarOpen,
|
||||
onOpenChange: setIsDocumentsSidebarOpen,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LayoutShell>
|
||||
|
||||
{/* Delete Chat Dialog */}
|
||||
<Dialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
<span>{t("delete_chat")}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<AlertDialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}>
|
||||
<AlertDialogContent className="sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("delete_chat")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "}
|
||||
{t("action_cannot_undone")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteChatDialog(false)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeletingChat}>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
confirmDeleteChat();
|
||||
}}
|
||||
disabled={isDeletingChat}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDeleteChat}
|
||||
disabled={isDeletingChat}
|
||||
className="gap-2"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||
>
|
||||
{isDeletingChat ? (
|
||||
<>
|
||||
|
|
@ -723,15 +689,12 @@ export function LayoutDataProvider({
|
|||
{t("deleting")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{tCommon("delete")}
|
||||
</>
|
||||
tCommon("delete")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Rename Chat Dialog */}
|
||||
<Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}>
|
||||
|
|
@ -756,7 +719,7 @@ export function LayoutDataProvider({
|
|||
/>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
onClick={() => setShowRenameChatDialog(false)}
|
||||
disabled={isRenamingChat}
|
||||
>
|
||||
|
|
@ -773,10 +736,7 @@ export function LayoutDataProvider({
|
|||
{tSidebar("renaming") || "Renaming"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
{tSidebar("rename") || "Rename"}
|
||||
</>
|
||||
tSidebar("rename") || "Rename"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
@ -784,30 +744,25 @@ export function LayoutDataProvider({
|
|||
</Dialog>
|
||||
|
||||
{/* Delete Search Space Dialog */}
|
||||
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
<span>{t("delete_search_space")}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<AlertDialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
|
||||
<AlertDialogContent className="sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("delete_search_space")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteSearchSpaceDialog(false)}
|
||||
disabled={isDeletingSearchSpace}
|
||||
>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeletingSearchSpace}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDeleteSearchSpace}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
confirmDeleteSearchSpace();
|
||||
}}
|
||||
disabled={isDeletingSearchSpace}
|
||||
className="gap-2"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||
>
|
||||
{isDeletingSearchSpace ? (
|
||||
<>
|
||||
|
|
@ -815,41 +770,33 @@ export function LayoutDataProvider({
|
|||
{t("deleting")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{tCommon("delete")}
|
||||
</>
|
||||
tCommon("delete")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Leave Search Space Dialog */}
|
||||
<Dialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LogOut className="h-5 w-5 text-destructive" />
|
||||
<span>{t("leave_title")}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<AlertDialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
|
||||
<AlertDialogContent className="sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("leave_title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("leave_confirm", { name: searchSpaceToLeave?.name || "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowLeaveSearchSpaceDialog(false)}
|
||||
disabled={isLeavingSearchSpace}
|
||||
>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLeavingSearchSpace}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmLeaveSearchSpace}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
confirmLeaveSearchSpace();
|
||||
}}
|
||||
disabled={isLeavingSearchSpace}
|
||||
className="gap-2"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||
>
|
||||
{isLeavingSearchSpace ? (
|
||||
<>
|
||||
|
|
@ -857,15 +804,12 @@ export function LayoutDataProvider({
|
|||
{t("leaving")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{t("leave")}
|
||||
</>
|
||||
t("leave")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Create Search Space Dialog */}
|
||||
<CreateSearchSpaceDialog
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface NavItem {
|
|||
icon: LucideIcon;
|
||||
isActive?: boolean;
|
||||
badge?: string | number;
|
||||
showSpinner?: boolean;
|
||||
}
|
||||
|
||||
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
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
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")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -3,33 +3,30 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import { usePathname } from "next/navigation";
|
||||
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 type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||
|
||||
interface HeaderProps {
|
||||
breadcrumb?: React.ReactNode;
|
||||
mobileMenuTrigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
|
||||
export function Header({ mobileMenuTrigger }: HeaderProps) {
|
||||
const pathname = usePathname();
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
|
||||
// Check if we're on a chat page
|
||||
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||
|
||||
// Use Jotai atom for thread state (synced from chat page)
|
||||
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;
|
||||
|
||||
// Create minimal thread object for ChatShareButton (used for API calls)
|
||||
const threadForButton: ThreadRecord | null =
|
||||
hasThread && currentThreadState.id !== null
|
||||
? {
|
||||
id: currentThreadState.id,
|
||||
visibility: currentThreadState.visibility ?? "PRIVATE",
|
||||
// These fields are not used by ChatShareButton for display, only for checks
|
||||
created_by_id: null,
|
||||
search_space_id: 0,
|
||||
title: "",
|
||||
|
|
@ -39,22 +36,20 @@ export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
|
|||
}
|
||||
: null;
|
||||
|
||||
const handleVisibilityChange = (_visibility: ChatVisibility) => {
|
||||
// Visibility change is handled by ChatShareButton internally via Jotai
|
||||
// This callback can be used for additional side effects if needed
|
||||
};
|
||||
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
|
||||
|
||||
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">
|
||||
{/* Left side - Mobile menu trigger + Breadcrumb */}
|
||||
<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 + Model selector */}
|
||||
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||
{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>
|
||||
|
||||
{/* Right side - Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Share button - only show on chat pages when thread exists */}
|
||||
{hasThread && (
|
||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -159,13 +159,13 @@ export function SearchSpaceAvatar({
|
|||
)}
|
||||
{onSettings && onDelete && <DropdownMenuSeparator />}
|
||||
{onDelete && isOwner && (
|
||||
<DropdownMenuItem variant="destructive" onClick={onDelete}>
|
||||
<DropdownMenuItem onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{tCommon("delete")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && !isOwner && (
|
||||
<DropdownMenuItem variant="destructive" onClick={onDelete}>
|
||||
<DropdownMenuItem onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("leave")}
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -217,13 +217,13 @@ export function SearchSpaceAvatar({
|
|||
)}
|
||||
{onSettings && onDelete && <ContextMenuSeparator />}
|
||||
{onDelete && isOwner && (
|
||||
<ContextMenuItem variant="destructive" onClick={onDelete}>
|
||||
<ContextMenuItem onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{tCommon("delete")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onDelete && !isOwner && (
|
||||
<ContextMenuItem variant="destructive" onClick={onDelete}>
|
||||
<ContextMenuItem onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("leave")}
|
||||
</ContextMenuItem>
|
||||
|
|
|
|||
|
|
@ -14,37 +14,33 @@ import {
|
|||
AllPrivateChatsSidebar,
|
||||
AllSharedChatsSidebar,
|
||||
AnnouncementsSidebar,
|
||||
DocumentsSidebar,
|
||||
InboxSidebar,
|
||||
MobileSidebar,
|
||||
MobileSidebarTrigger,
|
||||
Sidebar,
|
||||
} from "../sidebar";
|
||||
|
||||
// Tab-specific data source props
|
||||
// Per-tab data source
|
||||
interface TabDataSource {
|
||||
items: InboxItem[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
loadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
loadMore?: () => void;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
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 {
|
||||
isOpen: boolean;
|
||||
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;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
/** Whether the inbox is docked (permanent) */
|
||||
comments: TabDataSource;
|
||||
status: TabDataSource;
|
||||
isDocked?: boolean;
|
||||
/** Callback to change docked state */
|
||||
onDockedChange?: (docked: boolean) => void;
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +70,6 @@ interface LayoutShellProps {
|
|||
onUserSettings?: () => void;
|
||||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
breadcrumb?: React.ReactNode;
|
||||
theme?: string;
|
||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||
defaultCollapsed?: boolean;
|
||||
|
|
@ -99,6 +94,10 @@ interface LayoutShellProps {
|
|||
onOpenChange: (open: boolean) => void;
|
||||
searchSpaceId: string;
|
||||
};
|
||||
documentsPanel?: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function LayoutShell({
|
||||
|
|
@ -127,7 +126,6 @@ export function LayoutShell({
|
|||
onUserSettings,
|
||||
onLogout,
|
||||
pageUsage,
|
||||
breadcrumb,
|
||||
theme,
|
||||
setTheme,
|
||||
defaultCollapsed = false,
|
||||
|
|
@ -139,6 +137,7 @@ export function LayoutShell({
|
|||
isLoadingChats = false,
|
||||
allSharedChatsPanel,
|
||||
allPrivateChatsPanel,
|
||||
documentsPanel,
|
||||
}: LayoutShellProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
|
@ -162,7 +161,6 @@ export function LayoutShell({
|
|||
<TooltipProvider delayDuration={0}>
|
||||
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
||||
<Header
|
||||
breadcrumb={breadcrumb}
|
||||
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
||||
/>
|
||||
|
||||
|
|
@ -208,16 +206,22 @@ export function LayoutShell({
|
|||
<InboxSidebar
|
||||
open={inbox.isOpen}
|
||||
onOpenChange={inbox.onOpenChange}
|
||||
mentions={inbox.mentions}
|
||||
comments={inbox.comments}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
markAsRead={inbox.markAsRead}
|
||||
markAllAsRead={inbox.markAllAsRead}
|
||||
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 && (
|
||||
<AnnouncementsSidebar
|
||||
open={announcementsPanel.open}
|
||||
|
|
@ -307,18 +311,16 @@ export function LayoutShell({
|
|||
<InboxSidebar
|
||||
open={inbox.isOpen}
|
||||
onOpenChange={inbox.onOpenChange}
|
||||
mentions={inbox.mentions}
|
||||
comments={inbox.comments}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
markAsRead={inbox.markAsRead}
|
||||
markAllAsRead={inbox.markAllAsRead}
|
||||
isDocked={inbox.isDocked}
|
||||
onDockedChange={inbox.onDockedChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<Header breadcrumb={breadcrumb} />
|
||||
<Header />
|
||||
|
||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
{children}
|
||||
|
|
@ -330,17 +332,23 @@ export function LayoutShell({
|
|||
<InboxSidebar
|
||||
open={inbox.isOpen}
|
||||
onOpenChange={inbox.onOpenChange}
|
||||
mentions={inbox.mentions}
|
||||
comments={inbox.comments}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
markAsRead={inbox.markAsRead}
|
||||
markAllAsRead={inbox.markAllAsRead}
|
||||
isDocked={false}
|
||||
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 && (
|
||||
<AnnouncementsSidebar
|
||||
open={announcementsPanel.open}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -40,6 +40,7 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import {
|
||||
deleteThread,
|
||||
|
|
@ -85,6 +86,15 @@ export function AllPrivateChatsSidebar({
|
|||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
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();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -357,7 +367,16 @@ export function AllPrivateChatsSidebar({
|
|||
{isMobile ? (
|
||||
<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}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||
>
|
||||
|
|
@ -396,7 +415,9 @@ export function AllPrivateChatsSidebar({
|
|||
size="icon"
|
||||
className={cn(
|
||||
"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"
|
||||
)}
|
||||
disabled={isBusy}
|
||||
|
|
@ -435,10 +456,7 @@ export function AllPrivateChatsSidebar({
|
|||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteThread(thread.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("delete") || "Delete"}</span>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -496,7 +514,7 @@ export function AllPrivateChatsSidebar({
|
|||
/>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
onClick={() => setShowRenameDialog(false)}
|
||||
disabled={isRenaming}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -40,6 +40,7 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import {
|
||||
deleteThread,
|
||||
|
|
@ -85,6 +86,15 @@ export function AllSharedChatsSidebar({
|
|||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
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();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -357,7 +367,16 @@ export function AllSharedChatsSidebar({
|
|||
{isMobile ? (
|
||||
<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}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||
>
|
||||
|
|
@ -396,7 +415,9 @@ export function AllSharedChatsSidebar({
|
|||
size="icon"
|
||||
className={cn(
|
||||
"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"
|
||||
)}
|
||||
disabled={isBusy}
|
||||
|
|
@ -435,10 +456,7 @@ export function AllSharedChatsSidebar({
|
|||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteThread(thread.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("delete") || "Delete"}</span>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -496,7 +514,7 @@ export function AllSharedChatsSidebar({
|
|||
/>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
onClick={() => setShowRenameDialog(false)}
|
||||
disabled={isRenaming}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
|
||||
import { AnnouncementCard } from "@/components/announcements/AnnouncementCard";
|
||||
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
|
|
@ -72,4 +72,3 @@ export function AnnouncementsSidebar({
|
|||
</SidebarSlideOutPanel>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -17,6 +18,8 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatListItemProps {
|
||||
|
|
@ -39,12 +42,24 @@ export function ChatListItem({
|
|||
onDelete,
|
||||
}: ChatListItemProps) {
|
||||
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 (
|
||||
<div className="group/item relative w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onClick={handleClick}
|
||||
{...(isMobile ? longPressHandlers : {})}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
||||
"[&>span:last-child]:truncate",
|
||||
|
|
@ -57,9 +72,14 @@ export function ChatListItem({
|
|||
<span className="w-[calc(100%-3rem)] ">{name}</span>
|
||||
</button>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<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">
|
||||
<DropdownMenu>
|
||||
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
|
||||
<div
|
||||
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>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
|
@ -105,7 +125,6 @@ export function ChatListItem({
|
|||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<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 { useTranslations } from "next-intl";
|
||||
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 { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
|
|
@ -49,6 +50,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|||
import {
|
||||
isCommentReplyMetadata,
|
||||
isConnectorIndexingMetadata,
|
||||
isDocumentProcessingMetadata,
|
||||
isNewMentionMetadata,
|
||||
isPageLimitExceededMetadata,
|
||||
} from "@/contracts/types/inbox.types";
|
||||
|
|
@ -60,9 +62,6 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||
|
||||
/**
|
||||
* Get initials from name or email for avatar fallback
|
||||
*/
|
||||
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
|
||||
if (name) {
|
||||
return name
|
||||
|
|
@ -79,9 +78,6 @@ function getInitials(name: string | null | undefined, email: string | null | und
|
|||
return "U";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
|
||||
*/
|
||||
function formatInboxCount(count: number): string {
|
||||
if (count <= 999) {
|
||||
return count.toString();
|
||||
|
|
@ -90,9 +86,6 @@ function formatInboxCount(count: number): string {
|
|||
return `${thousands}k+`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for connector type
|
||||
*/
|
||||
function getConnectorTypeDisplayName(connectorType: string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
GITHUB_CONNECTOR: "GitHub",
|
||||
|
|
@ -135,44 +128,36 @@ function getConnectorTypeDisplayName(connectorType: string): string {
|
|||
}
|
||||
|
||||
type InboxTab = "comments" | "status";
|
||||
type InboxFilter = "all" | "unread";
|
||||
type InboxFilter = "all" | "unread" | "errors";
|
||||
|
||||
// Tab-specific data source with independent pagination
|
||||
interface TabDataSource {
|
||||
items: InboxItem[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
loadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
loadMore?: () => void;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface InboxSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Mentions tab data source with independent pagination */
|
||||
mentions: TabDataSource;
|
||||
/** Status tab data source with independent pagination */
|
||||
comments: TabDataSource;
|
||||
status: TabDataSource;
|
||||
/** Combined unread count for mark all as read */
|
||||
totalUnreadCount: number;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
/** Whether the inbox is docked (permanent) or floating */
|
||||
isDocked?: boolean;
|
||||
/** Callback to toggle docked state */
|
||||
onDockedChange?: (docked: boolean) => void;
|
||||
}
|
||||
|
||||
export function InboxSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
mentions,
|
||||
comments,
|
||||
status,
|
||||
totalUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
onCloseMobileSidebar,
|
||||
isDocked = false,
|
||||
onDockedChange,
|
||||
|
|
@ -183,9 +168,7 @@ export function InboxSidebar({
|
|||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
||||
|
||||
// Comments collapsed state (desktop only, when docked)
|
||||
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
|
||||
// Target comment for navigation - also ensures comments panel is visible
|
||||
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
|
@ -193,11 +176,9 @@ export function InboxSidebar({
|
|||
const isSearchMode = !!debouncedSearch.trim();
|
||||
const [activeTab, setActiveTab] = useState<InboxTab>("comments");
|
||||
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);
|
||||
// Dropdown state for filter menu (desktop only)
|
||||
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
||||
// Scroll shadow state for connector list
|
||||
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
|
|
@ -205,15 +186,12 @@ export function InboxSidebar({
|
|||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
// Drawer state for filter menu (mobile only)
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||
|
||||
// Prefetch trigger ref - placed on item near the end
|
||||
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Server-side search query (enabled only when user is typing a search)
|
||||
// Determines which notification types to search based on active tab
|
||||
// Server-side search query
|
||||
const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined;
|
||||
const { data: searchResponse, isLoading: isSearchLoading } = useQuery({
|
||||
queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab),
|
||||
|
|
@ -226,7 +204,7 @@ export function InboxSidebar({
|
|||
limit: 50,
|
||||
},
|
||||
}),
|
||||
staleTime: 30 * 1000, // 30 seconds (search results don't need to be super fresh)
|
||||
staleTime: 30 * 1000,
|
||||
enabled: isSearchMode && open,
|
||||
});
|
||||
|
||||
|
|
@ -244,129 +222,128 @@ export function InboxSidebar({
|
|||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
// Only lock body scroll on mobile when inbox is open
|
||||
useEffect(() => {
|
||||
if (!open || !isMobile) return;
|
||||
|
||||
// Store original overflow to restore on cleanup
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow;
|
||||
};
|
||||
}, [open, isMobile]);
|
||||
|
||||
// Reset connector filter when switching away from status tab
|
||||
useEffect(() => {
|
||||
if (activeTab !== "status") {
|
||||
setSelectedConnector(null);
|
||||
setSelectedSource(null);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Each tab uses its own data source for independent pagination
|
||||
// Comments tab: uses mentions data source (fetches only mention/reply types from server)
|
||||
const commentsItems = mentions.items;
|
||||
// Active tab's data source — fully independent loading, pagination, and counts
|
||||
const activeSource = activeTab === "comments" ? comments : status;
|
||||
|
||||
// Status tab: filters status data source (fetches all types) to status-specific types
|
||||
const statusItems = useMemo(
|
||||
() =>
|
||||
status.items.filter(
|
||||
(item) =>
|
||||
item.type === "connector_indexing" ||
|
||||
item.type === "document_processing" ||
|
||||
item.type === "page_limit_exceeded" ||
|
||||
item.type === "connector_deletion"
|
||||
),
|
||||
[status.items]
|
||||
// Fetch source types for the status tab filter
|
||||
const { data: sourceTypesData } = useQuery({
|
||||
queryKey: cacheKeys.notifications.sourceTypes(searchSpaceId),
|
||||
queryFn: () => notificationsApiService.getSourceTypes(searchSpaceId ?? undefined),
|
||||
staleTime: 60 * 1000,
|
||||
enabled: open && activeTab === "status",
|
||||
});
|
||||
|
||||
const statusSourceOptions = useMemo(() => {
|
||||
if (!sourceTypesData?.sources) return [];
|
||||
|
||||
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
|
||||
const loading = activeTab === "comments" ? mentions.loading : status.loading;
|
||||
const loadingMore =
|
||||
activeTab === "comments" ? (mentions.loadingMore ?? false) : (status.loadingMore ?? false);
|
||||
const hasMore =
|
||||
activeTab === "comments" ? (mentions.hasMore ?? false) : (status.hasMore ?? false);
|
||||
const loadMore = activeTab === "comments" ? mentions.loadMore : status.loadMore;
|
||||
// Client-side filter: unread / errors
|
||||
const matchesActiveFilter = useCallback(
|
||||
(item: InboxItem): boolean => {
|
||||
if (activeFilter === "unread") return !item.read;
|
||||
if (activeFilter === "errors") {
|
||||
if (item.type === "page_limit_exceeded") return true;
|
||||
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
|
||||
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)
|
||||
// Two data paths: search mode (API) or default (per-tab data source)
|
||||
const filteredItems = useMemo(() => {
|
||||
// In search mode, use API results
|
||||
let items: InboxItem[] = isSearchMode ? (searchResponse?.items ?? []) : displayItems;
|
||||
let tabItems: InboxItem[];
|
||||
|
||||
// For status tab search results, filter to status-specific types
|
||||
if (isSearchMode && activeTab === "status") {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.type === "connector_indexing" ||
|
||||
item.type === "document_processing" ||
|
||||
item.type === "page_limit_exceeded" ||
|
||||
item.type === "connector_deletion"
|
||||
);
|
||||
if (isSearchMode) {
|
||||
tabItems = searchResponse?.items ?? [];
|
||||
} else {
|
||||
tabItems = activeSource.items;
|
||||
}
|
||||
|
||||
// Apply read/unread filter
|
||||
if (activeFilter === "unread") {
|
||||
items = items.filter((item) => !item.read);
|
||||
let result = tabItems;
|
||||
if (activeFilter !== "all") {
|
||||
result = result.filter(matchesActiveFilter);
|
||||
}
|
||||
if (activeTab === "status" && selectedSource) {
|
||||
result = result.filter(matchesSourceFilter);
|
||||
}
|
||||
|
||||
// Apply connector filter (only for status tab)
|
||||
if (activeTab === "status" && selectedConnector) {
|
||||
items = items.filter((item) => {
|
||||
if (item.type === "connector_indexing") {
|
||||
// Use type guard for safe metadata access
|
||||
if (isConnectorIndexingMetadata(item.metadata)) {
|
||||
return item.metadata.connector_type === selectedConnector;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false; // Hide document_processing when a specific connector is selected
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [
|
||||
isSearchMode,
|
||||
searchResponse,
|
||||
activeSource.items,
|
||||
activeTab,
|
||||
activeFilter,
|
||||
selectedSource,
|
||||
matchesActiveFilter,
|
||||
matchesSourceFilter,
|
||||
]);
|
||||
|
||||
return items;
|
||||
}, [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)
|
||||
// Infinite scroll — uses active tab's pagination
|
||||
useEffect(() => {
|
||||
if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return;
|
||||
if (!activeSource.hasMore || activeSource.loadingMore || !open || isSearchMode) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// When trigger element is visible, load more
|
||||
if (entries[0]?.isIntersecting) {
|
||||
loadMore();
|
||||
activeSource.loadMore();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null, // viewport
|
||||
rootMargin: "100px", // Start loading 100px before visible
|
||||
root: null,
|
||||
rootMargin: "100px",
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
|
@ -376,17 +353,13 @@ export function InboxSidebar({
|
|||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]);
|
||||
|
||||
// Unread counts from server-side accurate totals (passed via props)
|
||||
const unreadCommentsCount = mentions.unreadCount;
|
||||
const unreadStatusCount = status.unreadCount;
|
||||
}, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, open, isSearchMode]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
async (item: InboxItem) => {
|
||||
if (!item.read) {
|
||||
setMarkingAsReadId(item.id);
|
||||
await markAsRead(item.id);
|
||||
await activeSource.markAsRead(item.id);
|
||||
setMarkingAsReadId(null);
|
||||
}
|
||||
|
||||
|
|
@ -427,7 +400,6 @@ export function InboxSidebar({
|
|||
}
|
||||
}
|
||||
} else if (item.type === "page_limit_exceeded") {
|
||||
// Navigate to the upgrade/more-pages page
|
||||
if (isPageLimitExceededMetadata(item.metadata)) {
|
||||
const actionUrl = item.metadata.action_url;
|
||||
if (actionUrl) {
|
||||
|
|
@ -438,12 +410,12 @@ export function InboxSidebar({
|
|||
}
|
||||
}
|
||||
},
|
||||
[markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
|
||||
[activeSource.markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
|
||||
);
|
||||
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
await markAllAsRead();
|
||||
}, [markAllAsRead]);
|
||||
await Promise.all([comments.markAllAsRead(), status.markAllAsRead()]);
|
||||
}, [comments.markAllAsRead, status.markAllAsRead]);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
|
|
@ -469,7 +441,6 @@ export function InboxSidebar({
|
|||
};
|
||||
|
||||
const getStatusIcon = (item: InboxItem) => {
|
||||
// For mentions and comment replies, show the author's avatar
|
||||
if (item.type === "new_mention" || item.type === "comment_reply") {
|
||||
const metadata =
|
||||
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") {
|
||||
return (
|
||||
<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 status = typeof metadata?.status === "string" ? metadata.status : undefined;
|
||||
|
||||
|
|
@ -558,13 +526,13 @@ export function InboxSidebar({
|
|||
|
||||
if (!mounted) return null;
|
||||
|
||||
// Shared content component for both docked and floating modes
|
||||
const isLoading = isSearchMode ? isSearchLoading : activeSource.loading;
|
||||
|
||||
const inboxContent = (
|
||||
<>
|
||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Back button - mobile only */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -579,7 +547,6 @@ export function InboxSidebar({
|
|||
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Mobile: Button that opens bottom drawer */}
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Button
|
||||
|
|
@ -605,7 +572,6 @@ export function InboxSidebar({
|
|||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Filter section */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||
{t("filter") || "Filter"}
|
||||
|
|
@ -649,56 +615,74 @@ export function InboxSidebar({
|
|||
</span>
|
||||
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||
</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>
|
||||
{/* Connectors section - only for status tab */}
|
||||
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
||||
{activeTab === "status" && statusSourceOptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||
{t("connectors") || "Connectors"}
|
||||
{t("sources") || "Sources"}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedConnector(null);
|
||||
setSelectedSource(null);
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"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"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span>{t("all_connectors") || "All connectors"}</span>
|
||||
<span>{t("all_sources") || "All sources"}</span>
|
||||
</span>
|
||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||
{selectedSource === null && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
{statusSourceOptions.map((source) => (
|
||||
<button
|
||||
key={connector.type}
|
||||
key={source.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedConnector(connector.type);
|
||||
setSelectedSource(source.key);
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"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"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
{getConnectorIcon(source.type, "h-4 w-4")}
|
||||
<span>{source.displayName}</span>
|
||||
</span>
|
||||
{selectedConnector === connector.type && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
{selectedSource === source.key && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -709,7 +693,6 @@ export function InboxSidebar({
|
|||
</Drawer>
|
||||
</>
|
||||
) : (
|
||||
/* Desktop: Dropdown menu */
|
||||
<DropdownMenu
|
||||
open={openDropdown === "filter"}
|
||||
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
|
||||
|
|
@ -727,7 +710,10 @@ export function InboxSidebar({
|
|||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
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">
|
||||
{t("filter") || "Filter"}
|
||||
|
|
@ -752,13 +738,25 @@ export function InboxSidebar({
|
|||
</span>
|
||||
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||
</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">
|
||||
{t("connectors") || "Connectors"}
|
||||
{t("sources") || "Sources"}
|
||||
</DropdownMenuLabel>
|
||||
<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}
|
||||
style={{
|
||||
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
|
||||
onClick={() => setSelectedConnector(null)}
|
||||
onClick={() => setSelectedSource(null)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span>{t("all_connectors") || "All connectors"}</span>
|
||||
<span>{t("all_sources") || "All sources"}</span>
|
||||
</span>
|
||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||
{selectedSource === null && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
{statusSourceOptions.map((source) => (
|
||||
<DropdownMenuItem
|
||||
key={connector.type}
|
||||
onClick={() => setSelectedConnector(connector.type)}
|
||||
key={source.key}
|
||||
onClick={() => setSelectedSource(source.key)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
{getConnectorIcon(source.type, "h-4 w-4")}
|
||||
<span>{source.displayName}</span>
|
||||
</span>
|
||||
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
|
||||
{selectedSource === source.key && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -824,7 +822,6 @@ export function InboxSidebar({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Dock/Undock button - desktop only */}
|
||||
{!isMobile && onDockedChange && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -834,12 +831,10 @@ export function InboxSidebar({
|
|||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => {
|
||||
if (isDocked) {
|
||||
// Collapse: show comments immediately, then close inbox
|
||||
setCommentsCollapsed(false);
|
||||
onDockedChange(false);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
// Expand: hide comments immediately
|
||||
setCommentsCollapsed(true);
|
||||
onDockedChange(true);
|
||||
}
|
||||
|
|
@ -886,7 +881,13 @@ export function InboxSidebar({
|
|||
|
||||
<Tabs
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
<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">
|
||||
{formatInboxCount(unreadCommentsCount)}
|
||||
{formatInboxCount(comments.unreadCount)}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
|
|
@ -910,7 +911,7 @@ export function InboxSidebar({
|
|||
<History className="h-4 w-4" />
|
||||
<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">
|
||||
{formatInboxCount(unreadStatusCount)}
|
||||
{formatInboxCount(status.unreadCount)}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
|
|
@ -918,11 +919,10 @@ export function InboxSidebar({
|
|||
</Tabs>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{(isSearchMode ? isSearchLoading : loading) ? (
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{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
|
||||
key={`skeleton-comment-${i}`}
|
||||
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" />
|
||||
</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
|
||||
key={`skeleton-status-${i}`}
|
||||
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">
|
||||
{filteredItems.map((item, index) => {
|
||||
const isMarkingAsRead = markingAsReadId === item.id;
|
||||
// Place prefetch trigger on 5th item from end (only when not searching)
|
||||
const isPrefetchTrigger =
|
||||
!isSearchMode && hasMore && index === filteredItems.length - 5;
|
||||
!isSearchMode && activeSource.hasMore && index === filteredItems.length - 5;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -1028,7 +1026,6 @@ export function InboxSidebar({
|
|||
</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">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatTime(item.created_at)}
|
||||
|
|
@ -1038,12 +1035,10 @@ export function InboxSidebar({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Fallback trigger at the very end if less than 5 items and not searching */}
|
||||
{!isSearchMode && filteredItems.length < 5 && hasMore && (
|
||||
{!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && (
|
||||
<div ref={prefetchTriggerRef} className="h-1" />
|
||||
)}
|
||||
{/* Loading more skeletons at the bottom during infinite scroll */}
|
||||
{loadingMore &&
|
||||
{activeSource.loadingMore &&
|
||||
(activeTab === "comments"
|
||||
? [80, 60, 90].map((titleWidth, i) => (
|
||||
<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) {
|
||||
return (
|
||||
<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"}
|
||||
>
|
||||
{inboxContent}
|
||||
|
|
@ -1112,7 +1106,6 @@ export function InboxSidebar({
|
|||
);
|
||||
}
|
||||
|
||||
// FLOATING MODE: Render with animation and click-away layer
|
||||
return (
|
||||
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
|
||||
{inboxContent}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { NavItem } from "../../types/layout.types";
|
||||
|
|
@ -39,11 +40,15 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
{...joyrideAttr}
|
||||
>
|
||||
<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">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<span className="sr-only">{item.title}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -67,7 +72,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
)}
|
||||
{...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>
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { PanelLeft, PanelLeftClose } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
|
||||
|
||||
|
|
@ -18,7 +19,7 @@ export function SidebarCollapseButton({
|
|||
disableTooltip = false,
|
||||
}: SidebarCollapseButtonProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const { shortcut } = usePlatformShortcut();
|
||||
const { shortcutKeys } = usePlatformShortcut();
|
||||
|
||||
const button = (
|
||||
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
|
||||
|
|
@ -35,9 +36,10 @@ export function SidebarCollapseButton({
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
|
||||
{isCollapsed
|
||||
? `${t("expand_sidebar")} ${shortcut("Mod", "\\")}`
|
||||
: `${t("collapse_sidebar")} ${shortcut("Mod", "\\")}`}
|
||||
<span className="flex items-center">
|
||||
{isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
|
||||
<ShortcutKbd keys={shortcutKeys("Mod", "\\")} />
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -56,7 +55,6 @@ export function SidebarHeader({
|
|||
<UserPen className="h-4 w-4" />
|
||||
{t("manage_members")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onSettings}>
|
||||
<Settings className="h-4 w-4" />
|
||||
{t("search_space_settings")}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function SidebarSlideOutPanel({
|
|||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
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"
|
||||
)}
|
||||
role="dialog"
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ export function SidebarUserProfile({
|
|||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||
|
||||
<DropdownMenuItem onClick={onUserSettings}>
|
||||
<Settings className="h-4 w-4" />
|
||||
|
|
@ -256,7 +256,7 @@ export function SidebarUserProfile({
|
|||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||
|
||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||
{isLoggingOut ? (
|
||||
|
|
@ -310,7 +310,7 @@ export function SidebarUserProfile({
|
|||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||
|
||||
<DropdownMenuItem onClick={onUserSettings}>
|
||||
<Settings className="h-4 w-4" />
|
||||
|
|
@ -378,7 +378,7 @@ export function SidebarUserProfile({
|
|||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||
|
||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||
{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 { AnnouncementsSidebar } from "./AnnouncementsSidebar";
|
||||
export { ChatListItem } from "./ChatListItem";
|
||||
export { DocumentsSidebar } from "./DocumentsSidebar";
|
||||
export { InboxSidebar } from "./InboxSidebar";
|
||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||
export { NavSection } from "./NavSection";
|
||||
|
|
|
|||
|
|
@ -7,38 +7,39 @@ import type {
|
|||
ImageGenerationConfig,
|
||||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { ImageConfigSidebar } from "./image-config-sidebar";
|
||||
import { ModelConfigSidebar } from "./model-config-sidebar";
|
||||
import { ImageConfigDialog } from "./image-config-dialog";
|
||||
import { ModelConfigDialog } from "./model-config-dialog";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
searchSpaceId: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
||||
// LLM config sidebar state
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
|
||||
// LLM config dialog state
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [selectedConfig, setSelectedConfig] = useState<
|
||||
NewLLMConfigPublic | GlobalNewLLMConfig | null
|
||||
>(null);
|
||||
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
|
||||
const [imageSidebarOpen, setImageSidebarOpen] = useState(false);
|
||||
// Image config dialog state
|
||||
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
||||
const [selectedImageConfig, setSelectedImageConfig] = useState<
|
||||
ImageGenerationConfig | GlobalImageGenConfig | null
|
||||
>(null);
|
||||
const [isImageGlobal, setIsImageGlobal] = useState(false);
|
||||
const [imageSidebarMode, setImageSidebarMode] = useState<"create" | "edit" | "view">("view");
|
||||
const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view");
|
||||
|
||||
// LLM handlers
|
||||
const handleEditLLMConfig = useCallback(
|
||||
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
|
||||
setSelectedConfig(config);
|
||||
setIsGlobal(global);
|
||||
setSidebarMode(global ? "view" : "edit");
|
||||
setSidebarOpen(true);
|
||||
setDialogMode(global ? "view" : "edit");
|
||||
setDialogOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
|
@ -46,12 +47,12 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
|||
const handleAddNewLLM = useCallback(() => {
|
||||
setSelectedConfig(null);
|
||||
setIsGlobal(false);
|
||||
setSidebarMode("create");
|
||||
setSidebarOpen(true);
|
||||
setDialogMode("create");
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSidebarClose = useCallback((open: boolean) => {
|
||||
setSidebarOpen(open);
|
||||
const handleDialogClose = useCallback((open: boolean) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) setSelectedConfig(null);
|
||||
}, []);
|
||||
|
||||
|
|
@ -59,22 +60,22 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
|||
const handleAddImageModel = useCallback(() => {
|
||||
setSelectedImageConfig(null);
|
||||
setIsImageGlobal(false);
|
||||
setImageSidebarMode("create");
|
||||
setImageSidebarOpen(true);
|
||||
setImageDialogMode("create");
|
||||
setImageDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditImageConfig = useCallback(
|
||||
(config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => {
|
||||
setSelectedImageConfig(config);
|
||||
setIsImageGlobal(global);
|
||||
setImageSidebarMode(global ? "view" : "edit");
|
||||
setImageSidebarOpen(true);
|
||||
setImageDialogMode(global ? "view" : "edit");
|
||||
setImageDialogOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleImageSidebarClose = useCallback((open: boolean) => {
|
||||
setImageSidebarOpen(open);
|
||||
const handleImageDialogClose = useCallback((open: boolean) => {
|
||||
setImageDialogOpen(open);
|
||||
if (!open) setSelectedImageConfig(null);
|
||||
}, []);
|
||||
|
||||
|
|
@ -85,22 +86,23 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
|||
onAddNewLLM={handleAddNewLLM}
|
||||
onEditImage={handleEditImageConfig}
|
||||
onAddNewImage={handleAddImageModel}
|
||||
className={className}
|
||||
/>
|
||||
<ModelConfigSidebar
|
||||
open={sidebarOpen}
|
||||
onOpenChange={handleSidebarClose}
|
||||
<ModelConfigDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={handleDialogClose}
|
||||
config={selectedConfig}
|
||||
isGlobal={isGlobal}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={sidebarMode}
|
||||
mode={dialogMode}
|
||||
/>
|
||||
<ImageConfigSidebar
|
||||
open={imageSidebarOpen}
|
||||
onOpenChange={handleImageSidebarClose}
|
||||
<ImageConfigDialog
|
||||
open={imageDialogOpen}
|
||||
onOpenChange={handleImageDialogClose}
|
||||
config={selectedImageConfig}
|
||||
isGlobal={isImageGlobal}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={imageSidebarMode}
|
||||
mode={imageDialogMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -72,12 +72,15 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
// Query to check if thread has public snapshots
|
||||
const { data: snapshotsData } = useQuery({
|
||||
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,
|
||||
staleTime: 30000, // Cache for 30 seconds
|
||||
});
|
||||
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
|
||||
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" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{snapshotCount === 1
|
||||
? "This chat has a public link"
|
||||
: `This chat has ${snapshotCount} public links`}
|
||||
</TooltipContent>
|
||||
<TooltipContent>Manage public links</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
|
|
@ -167,7 +166,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
<Button
|
||||
variant="outline"
|
||||
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" />
|
||||
<span className="hidden md:inline text-sm">{buttonLabel}</span>
|
||||
|
|
@ -178,12 +177,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
</Tooltip>
|
||||
|
||||
<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"
|
||||
sideOffset={8}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="p-1.5 space-y-1 select-none">
|
||||
<div className="p-1.5 space-y-1">
|
||||
{/* Visibility Options */}
|
||||
{visibilityOptions.map((option) => {
|
||||
const isSelected = currentVisibility === option.value;
|
||||
|
|
@ -196,27 +195,32 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
onClick={() => handleVisibilityChange(option.value)}
|
||||
className={cn(
|
||||
"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",
|
||||
isSelected && "bg-accent/80"
|
||||
isSelected && "bg-accent/80 dark:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"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
|
||||
className={cn(
|
||||
"size-4 block",
|
||||
isSelected ? "text-primary" : "text-muted-foreground"
|
||||
isSelected ? "text-primary dark:text-white" : "text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -231,7 +235,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
{canCreatePublicLink && (
|
||||
<>
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border my-1" />
|
||||
<div className="border-t border-border dark:border-white/5 my-1" />
|
||||
|
||||
{/* Public Link Option */}
|
||||
<button
|
||||
|
|
@ -240,12 +244,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
disabled={isCreatingSnapshot}
|
||||
className={cn(
|
||||
"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",
|
||||
"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" />
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
|
|
|
|||
|
|
@ -1,19 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
Key,
|
||||
Shuffle,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { AlertCircle, Check, ChevronsUpDown, X } from "lucide-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 { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -48,10 +38,11 @@ import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-g
|
|||
import type {
|
||||
GlobalImageGenConfig,
|
||||
ImageGenerationConfig,
|
||||
ImageGenProvider,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImageConfigSidebarProps {
|
||||
interface ImageConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: ImageGenerationConfig | GlobalImageGenConfig | null;
|
||||
|
|
@ -70,24 +61,25 @@ const INITIAL_FORM = {
|
|||
api_version: "",
|
||||
};
|
||||
|
||||
export function ImageConfigSidebar({
|
||||
export function ImageConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
isGlobal,
|
||||
searchSpaceId,
|
||||
mode,
|
||||
}: ImageConfigSidebarProps) {
|
||||
}: ImageConfigDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [formData, setFormData] = useState(INITIAL_FORM);
|
||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Reset form when opening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (mode === "edit" && config && !isGlobal) {
|
||||
|
|
@ -103,15 +95,14 @@ export function ImageConfigSidebar({
|
|||
} else if (mode === "create") {
|
||||
setFormData(INITIAL_FORM);
|
||||
}
|
||||
setScrollPos("top");
|
||||
}
|
||||
}, [open, mode, config, isGlobal]);
|
||||
|
||||
// Mutations
|
||||
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
|
||||
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
// Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) onOpenChange(false);
|
||||
|
|
@ -120,6 +111,13 @@ export function ImageConfigSidebar({
|
|||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [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 suggestedModels = useMemo(() => {
|
||||
|
|
@ -134,13 +132,20 @@ export function ImageConfigSidebar({
|
|||
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 () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const result = await createConfig({
|
||||
name: formData.name,
|
||||
provider: formData.provider,
|
||||
provider: formData.provider as ImageGenProvider,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
|
|
@ -148,7 +153,6 @@ export function ImageConfigSidebar({
|
|||
description: formData.description || undefined,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
// Set as active image model
|
||||
if (result?.id) {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
|
|
@ -163,7 +167,7 @@ export function ImageConfigSidebar({
|
|||
data: {
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
provider: formData.provider,
|
||||
provider: formData.provider as ImageGenProvider,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
|
|
@ -214,126 +218,96 @@ export function ImageConfigSidebar({
|
|||
|
||||
if (!mounted) return null;
|
||||
|
||||
const sidebarContent = (
|
||||
const dialogContent = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm"
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<motion.div
|
||||
initial={{ x: "100%", opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: "100%", opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
||||
className={cn(
|
||||
"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"
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
"flex items-center justify-between px-6 py-4 border-b border-border/50",
|
||||
isAutoMode
|
||||
? "bg-gradient-to-r from-violet-500/10 to-purple-500/10"
|
||||
: "bg-gradient-to-r from-teal-500/10 to-cyan-500/10"
|
||||
"relative w-full max-w-lg h-[85vh]",
|
||||
"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">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center size-10 rounded-xl",
|
||||
isAutoMode
|
||||
? "bg-gradient-to-br from-violet-500 to-purple-600"
|
||||
: "bg-gradient-to-br from-teal-500 to-cyan-600"
|
||||
)}
|
||||
>
|
||||
{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" />
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4">
|
||||
<div className="space-y-1 pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||
{isAutoMode && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Recommended
|
||||
</Badge>
|
||||
) : isGlobal ? (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Globe className="size-3" />
|
||||
)}
|
||||
{isGlobal && !isAutoMode && mode !== "create" && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Global
|
||||
</Badge>
|
||||
) : null}
|
||||
{config && !isAutoMode && (
|
||||
<span className="text-xs text-muted-foreground">{config.model_name}</span>
|
||||
)}
|
||||
</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>
|
||||
<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>
|
||||
<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 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6">
|
||||
{/* Auto mode */}
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
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 && (
|
||||
<>
|
||||
<Alert className="mb-6 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">
|
||||
Auto mode distributes image generation requests across all configured
|
||||
providers for optimal performance and rate limit protection.
|
||||
</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>
|
||||
</>
|
||||
<Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
|
||||
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
||||
Auto mode distributes image generation requests across all configured
|
||||
providers for optimal performance and rate limit protection.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Global config (read-only) */}
|
||||
{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" />
|
||||
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
||||
Global configurations are read-only. To customize, create a new model.
|
||||
|
|
@ -372,29 +346,11 @@ export function ImageConfigSidebar({
|
|||
</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)) && (
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Name *</Label>
|
||||
<Input
|
||||
|
|
@ -404,7 +360,6 @@ export function ImageConfigSidebar({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Description</Label>
|
||||
<Input
|
||||
|
|
@ -418,7 +373,6 @@ export function ImageConfigSidebar({
|
|||
|
||||
<Separator />
|
||||
|
||||
{/* Provider */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Provider *</Label>
|
||||
<Select
|
||||
|
|
@ -430,20 +384,16 @@ export function ImageConfigSidebar({
|
|||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="bg-muted dark:border-neutral-700">
|
||||
{IMAGE_GEN_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{p.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.example}</span>
|
||||
</div>
|
||||
<SelectItem key={p.value} value={p.value} description={p.example}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Model Name */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Model Name *</Label>
|
||||
{suggestedModels.length > 0 ? (
|
||||
|
|
@ -452,14 +402,17 @@ export function ImageConfigSidebar({
|
|||
<Button
|
||||
variant="outline"
|
||||
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..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<PopoverContent
|
||||
className="w-full p-0 bg-muted dark:border-neutral-700"
|
||||
align="start"
|
||||
>
|
||||
<Command className="bg-transparent">
|
||||
<CommandInput
|
||||
placeholder="Search or type model..."
|
||||
value={formData.model_name}
|
||||
|
|
@ -513,11 +466,8 @@ export function ImageConfigSidebar({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium flex items-center gap-1.5">
|
||||
<Key className="h-3.5 w-3.5" /> API Key *
|
||||
</Label>
|
||||
<Label className="text-sm font-medium">API Key *</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
|
|
@ -526,7 +476,6 @@ export function ImageConfigSidebar({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* API Base */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Base URL</Label>
|
||||
<Input
|
||||
|
|
@ -536,7 +485,6 @@ export function ImageConfigSidebar({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Azure API Version */}
|
||||
{formData.provider === "AZURE_OPENAI" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Version (Azure)</Label>
|
||||
|
|
@ -549,28 +497,56 @@ export function ImageConfigSidebar({
|
|||
/>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</motion.div>
|
||||
</>
|
||||
|
|
@ -578,5 +554,5 @@ export function ImageConfigSidebar({
|
|||
</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";
|
||||
|
||||
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 { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -15,13 +15,15 @@ import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-c
|
|||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type {
|
||||
GlobalNewLLMConfig,
|
||||
LiteLLMProvider,
|
||||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModelConfigSidebarProps {
|
||||
interface ModelConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
|
||||
|
|
@ -30,28 +32,34 @@ interface ModelConfigSidebarProps {
|
|||
mode: "create" | "edit" | "view";
|
||||
}
|
||||
|
||||
export function ModelConfigSidebar({
|
||||
export function ModelConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
isGlobal,
|
||||
searchSpaceId,
|
||||
mode,
|
||||
}: ModelConfigSidebarProps) {
|
||||
}: ModelConfigDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = 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(() => {
|
||||
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: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
|
|
@ -62,10 +70,8 @@ export function ModelConfigSidebar({
|
|||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
// Check if this is Auto mode
|
||||
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
|
||||
|
||||
// Get title based on mode
|
||||
const getTitle = () => {
|
||||
if (mode === "create") return "Add New Configuration";
|
||||
if (isAutoMode) return "Auto Mode (Fastest)";
|
||||
|
|
@ -73,19 +79,23 @@ export function ModelConfigSidebar({
|
|||
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(
|
||||
async (data: LLMConfigFormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
// Create new config
|
||||
const result = await createConfig({
|
||||
...data,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
|
||||
// Assign the new config to the agent role
|
||||
if (result?.id) {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
|
|
@ -98,7 +108,6 @@ export function ModelConfigSidebar({
|
|||
toast.success("Configuration created and assigned!");
|
||||
onOpenChange(false);
|
||||
} else if (!isGlobal && config) {
|
||||
// Update existing user config
|
||||
await updateConfig({
|
||||
id: config.id,
|
||||
data: {
|
||||
|
|
@ -137,7 +146,6 @@ export function ModelConfigSidebar({
|
|||
]
|
||||
);
|
||||
|
||||
// Handle "Use this model" for global configs
|
||||
const handleUseGlobalConfig = useCallback(async () => {
|
||||
if (!config || !isGlobal) return;
|
||||
setIsSubmitting(true);
|
||||
|
|
@ -160,7 +168,7 @@ export function ModelConfigSidebar({
|
|||
|
||||
if (!mounted) return null;
|
||||
|
||||
const sidebarContent = (
|
||||
const dialogContent = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
|
|
@ -169,93 +177,84 @@ export function ModelConfigSidebar({
|
|||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-[24] bg-black/20 backdrop-blur-sm"
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed inset-0 z-[24] bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{/* Sidebar Panel */}
|
||||
{/* Dialog */}
|
||||
<motion.div
|
||||
initial={{ x: "100%", opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: "100%", opacity: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
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"
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className="fixed inset-0 z-[25] flex items-center justify-center p-4 sm:p-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
"flex items-center justify-between px-6 py-4 border-b border-border/50",
|
||||
isAutoMode ? "bg-gradient-to-r from-violet-500/10 to-purple-500/10" : "bg-muted/20"
|
||||
"relative w-full max-w-lg h-[85vh]",
|
||||
"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">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center size-10 rounded-xl",
|
||||
isAutoMode ? "bg-gradient-to-br from-violet-500 to-purple-600" : "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
{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" />
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4">
|
||||
<div className="space-y-1 pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||
{isAutoMode && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Recommended
|
||||
</Badge>
|
||||
) : isGlobal ? (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Globe className="size-3" />
|
||||
)}
|
||||
{isGlobal && !isAutoMode && mode !== "create" && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Global
|
||||
</Badge>
|
||||
) : mode !== "create" ? (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<User className="size-3" />
|
||||
)}
|
||||
{!isGlobal && mode !== "create" && !isAutoMode && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Custom
|
||||
</Badge>
|
||||
) : null}
|
||||
{config && !isAutoMode && (
|
||||
<span className="text-xs text-muted-foreground">{config.model_name}</span>
|
||||
)}
|
||||
</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>
|
||||
<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>
|
||||
<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 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6">
|
||||
{/* Auto mode info banner */}
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
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 && (
|
||||
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5">
|
||||
<Shuffle className="size-4 text-violet-500" />
|
||||
<Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
|
||||
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
||||
Auto mode automatically distributes requests across all available LLM
|
||||
providers to optimize performance and avoid rate limits.
|
||||
|
|
@ -263,9 +262,8 @@ export function ModelConfigSidebar({
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Global config notice */}
|
||||
{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" />
|
||||
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
||||
Global configurations are read-only. To customize settings, create a new
|
||||
|
|
@ -274,20 +272,17 @@ export function ModelConfigSidebar({
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{mode === "create" ? (
|
||||
<LLMConfigForm
|
||||
searchSpaceId={searchSpaceId}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
isSubmitting={isSubmitting}
|
||||
mode="create"
|
||||
submitLabel="Create & Use"
|
||||
formId="model-config-form"
|
||||
hideActions
|
||||
/>
|
||||
) : isAutoMode && config ? (
|
||||
// Special view for Auto mode
|
||||
<div className="space-y-6">
|
||||
{/* Auto Mode Features */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
|
|
@ -339,36 +334,9 @@ export function ModelConfigSidebar({
|
|||
</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>
|
||||
) : isGlobal && config ? (
|
||||
// Read-only view for global configs
|
||||
<div className="space-y-6">
|
||||
{/* Config Details */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
|
|
@ -436,43 +404,17 @@ export function ModelConfigSidebar({
|
|||
</>
|
||||
)}
|
||||
</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>
|
||||
) : config ? (
|
||||
// Edit form for user configs
|
||||
<LLMConfigForm
|
||||
searchSpaceId={searchSpaceId}
|
||||
initialData={{
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
provider: config.provider,
|
||||
provider: config.provider as LiteLLMProvider,
|
||||
custom_provider: config.custom_provider,
|
||||
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,
|
||||
litellm_params: config.litellm_params,
|
||||
system_instructions: config.system_instructions,
|
||||
|
|
@ -481,13 +423,61 @@ export function ModelConfigSidebar({
|
|||
search_space_id: searchSpaceId,
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
isSubmitting={isSubmitting}
|
||||
mode="edit"
|
||||
submitLabel="Save Changes"
|
||||
formId="model-config-form"
|
||||
hideActions
|
||||
/>
|
||||
) : null}
|
||||
</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>
|
||||
</motion.div>
|
||||
</>
|
||||
|
|
@ -495,5 +485,5 @@ export function ModelConfigSidebar({
|
|||
</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 { 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 {
|
||||
globalImageGenConfigsAtom,
|
||||
|
|
@ -57,6 +57,17 @@ export function ModelSelector({
|
|||
const [activeTab, setActiveTab] = useState<"llm" | "image">("llm");
|
||||
const [llmSearchQuery, setLlmSearchQuery] = 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
|
||||
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
|
||||
|
|
@ -253,7 +264,7 @@ export function ModelSelector({
|
|||
)}
|
||||
|
||||
{/* 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 */}
|
||||
{currentImageConfig ? (
|
||||
|
|
@ -280,7 +291,7 @@ export function ModelSelector({
|
|||
</PopoverTrigger>
|
||||
|
||||
<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"
|
||||
sideOffset={8}
|
||||
>
|
||||
|
|
@ -289,7 +300,7 @@ export function ModelSelector({
|
|||
onValueChange={(v) => setActiveTab(v as "llm" | "image")}
|
||||
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">
|
||||
<TabsTrigger
|
||||
value="llm"
|
||||
|
|
@ -312,7 +323,7 @@ export function ModelSelector({
|
|||
<TabsContent value="llm" className="mt-0">
|
||||
<Command
|
||||
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 && (
|
||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||
|
|
@ -325,7 +336,14 @@ export function ModelSelector({
|
|||
</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">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Bot className="size-8 text-muted-foreground" />
|
||||
|
|
@ -350,8 +368,8 @@ export function ModelSelector({
|
|||
onSelect={() => handleSelectLLM(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50 dark:hover:bg-white/10",
|
||||
isSelected && "bg-accent/80 dark:bg-white/10",
|
||||
"hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||
isSelected && "bg-accent/80 dark:bg-white/[0.06]",
|
||||
isAutoMode && ""
|
||||
)}
|
||||
>
|
||||
|
|
@ -426,8 +444,8 @@ export function ModelSelector({
|
|||
onSelect={() => handleSelectLLM(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50 dark:hover:bg-white/10",
|
||||
isSelected && "bg-accent/80 dark:bg-white/10"
|
||||
"hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||
isSelected && "bg-accent/80 dark:bg-white/[0.06]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
|
|
@ -471,11 +489,11 @@ export function ModelSelector({
|
|||
)}
|
||||
|
||||
{/* 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
|
||||
variant="ghost"
|
||||
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={() => {
|
||||
setOpen(false);
|
||||
onAddNewLLM();
|
||||
|
|
@ -493,7 +511,7 @@ export function ModelSelector({
|
|||
<TabsContent value="image" className="mt-0">
|
||||
<Command
|
||||
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 && (
|
||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||
|
|
@ -505,7 +523,14 @@ export function ModelSelector({
|
|||
/>
|
||||
</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">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ImageIcon className="size-8 text-muted-foreground" />
|
||||
|
|
@ -528,8 +553,8 @@ export function ModelSelector({
|
|||
value={`img-g-${config.id}`}
|
||||
onSelect={() => handleSelectImage(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10",
|
||||
isSelected && "bg-accent/80 dark: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/[0.06]",
|
||||
isAuto && ""
|
||||
)}
|
||||
>
|
||||
|
|
@ -593,8 +618,8 @@ export function ModelSelector({
|
|||
value={`img-u-${config.id}`}
|
||||
onSelect={() => handleSelectImage(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10",
|
||||
isSelected && "bg-accent/80 dark: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/[0.06]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
|
|
@ -634,11 +659,11 @@ export function ModelSelector({
|
|||
|
||||
{/* Add New Image Config */}
|
||||
{onAddNewImage && (
|
||||
<div className="p-2 bg-muted/20 dark:bg-muted">
|
||||
<div className="p-2 bg-muted/20 dark:bg-neutral-900">
|
||||
<Button
|
||||
variant="ghost"
|
||||
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={() => {
|
||||
setOpen(false);
|
||||
onAddNewImage();
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ function ReportPanelContent({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
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")}>
|
||||
Download Markdown
|
||||
|
|
@ -371,7 +371,7 @@ function ReportPanelContent({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
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) => (
|
||||
<DropdownMenuItem
|
||||
|
|
|
|||
|
|
@ -578,10 +578,7 @@ function RolesContent({
|
|||
<DropdownMenuSeparator />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Role
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -2,16 +2,7 @@
|
|||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Key,
|
||||
MessageSquareQuote,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
|
@ -88,6 +79,8 @@ interface LLMConfigFormProps {
|
|||
submitLabel?: string;
|
||||
showAdvanced?: boolean;
|
||||
compact?: boolean;
|
||||
formId?: string;
|
||||
hideActions?: boolean;
|
||||
}
|
||||
|
||||
export function LLMConfigForm({
|
||||
|
|
@ -100,6 +93,8 @@ export function LLMConfigForm({
|
|||
submitLabel,
|
||||
showAdvanced = true,
|
||||
compact = false,
|
||||
formId,
|
||||
hideActions = false,
|
||||
}: LLMConfigFormProps) {
|
||||
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
|
||||
defaultSystemInstructionsAtom
|
||||
|
|
@ -164,11 +159,10 @@ export function LLMConfigForm({
|
|||
|
||||
return (
|
||||
<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 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
<Bot className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
Model Configuration
|
||||
</div>
|
||||
|
||||
|
|
@ -179,16 +173,9 @@ export function LLMConfigForm({
|
|||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
|
||||
Configuration Name
|
||||
</FormLabel>
|
||||
<FormLabel className="text-xs sm:text-sm">Configuration Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., My GPT-4 Agent"
|
||||
className="transition-all focus-visible:ring-violet-500/50"
|
||||
{...field}
|
||||
/>
|
||||
<Input placeholder="e.g., My GPT-4 Agent" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -224,19 +211,18 @@ export function LLMConfigForm({
|
|||
<FormLabel className="text-xs sm:text-sm">LLM Provider</FormLabel>
|
||||
<Select value={field.value} onValueChange={handleProviderChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="transition-all focus:ring-violet-500/50">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
<SelectContent className="max-h-[300px] bg-muted dark:border-neutral-700">
|
||||
{LLM_PROVIDERS.map((provider) => (
|
||||
<SelectItem key={provider.value} value={provider.value}>
|
||||
<div className="flex flex-col py-0.5">
|
||||
<span className="font-medium">{provider.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{provider.description}
|
||||
</span>
|
||||
</div>
|
||||
<SelectItem
|
||||
key={provider.value}
|
||||
value={provider.value}
|
||||
description={provider.description}
|
||||
>
|
||||
{provider.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -290,7 +276,7 @@ export function LLMConfigForm({
|
|||
role="combobox"
|
||||
aria-expanded={modelComboboxOpen}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
"w-full justify-between font-normal bg-transparent",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
|
|
@ -299,8 +285,11 @@ export function LLMConfigForm({
|
|||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<PopoverContent
|
||||
className="w-full p-0 bg-muted dark:border-neutral-700"
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false} className="bg-transparent">
|
||||
<CommandInput
|
||||
placeholder={selectedProvider?.example || "Type model name..."}
|
||||
value={field.value}
|
||||
|
|
@ -371,10 +360,7 @@ export function LLMConfigForm({
|
|||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<Key className="h-3.5 w-3.5 text-amber-500" />
|
||||
API Key
|
||||
</FormLabel>
|
||||
<FormLabel className="text-xs sm:text-sm">API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
|
|
@ -460,10 +446,7 @@ export function LLMConfigForm({
|
|||
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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
Advanced Parameters
|
||||
</div>
|
||||
<span>Advanced Parameters</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
|
|
@ -501,10 +484,7 @@ export function LLMConfigForm({
|
|||
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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquareQuote className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
System Instructions
|
||||
</div>
|
||||
<span>System Instructions</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
|
|
@ -575,42 +555,43 @@ export function LLMConfigForm({
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-3 pt-4",
|
||||
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")}
|
||||
</>
|
||||
{!hideActions && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-3 pt-4",
|
||||
compact ? "justify-end" : "justify-center sm:justify-end"
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useAtom } from "jotai";
|
||||
import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
|
|
@ -241,12 +241,7 @@ export function DocumentUploadTab({
|
|||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
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"
|
||||
>
|
||||
<div 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">
|
||||
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
|
||||
|
|
@ -287,14 +282,10 @@ export function DocumentUploadTab({
|
|||
</div>
|
||||
</div>
|
||||
) : isDragActive ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
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-primary" />
|
||||
<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">
|
||||
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" />
|
||||
|
|
@ -329,124 +320,102 @@ export function DocumentUploadTab({
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{files.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card className={cardClass}>
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base sm:text-2xl">
|
||||
{t("selected_files", { count: files.length })}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs sm:text-sm">
|
||||
{t("total_size")}: {formatFileSize(totalFileSize)}
|
||||
</CardDescription>
|
||||
{files.length > 0 && (
|
||||
<Card className={cardClass}>
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base sm:text-2xl">
|
||||
{t("selected_files", { count: files.length })}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs sm:text-sm">
|
||||
{t("total_size")}: {formatFileSize(totalFileSize)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm shrink-0"
|
||||
onClick={() => setFiles([])}
|
||||
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>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm shrink-0"
|
||||
onClick={() => setFiles([])}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setFiles((prev) => prev.filter((_, i) => i !== index))}
|
||||
disabled={isUploading}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{t("clear_all")}
|
||||
<X className="h-4 w-4" />
|
||||
</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">
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isUploading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-3 sm:mt-6 space-y-2 sm:space-y-3"
|
||||
>
|
||||
<Separator className="bg-border" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs sm:text-sm">
|
||||
<span>{t("uploading_files")}</span>
|
||||
<span>{Math.round(uploadProgress)}%</span>
|
||||
</div>
|
||||
<Progress value={uploadProgress} className="h-2" />
|
||||
</div>
|
||||
</motion.div>
|
||||
{isUploading && (
|
||||
<div className="mt-3 sm:mt-6 space-y-2 sm:space-y-3">
|
||||
<Separator className="bg-border" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs sm:text-sm">
|
||||
<span>{t("uploading_files")}</span>
|
||||
<span>{Math.round(uploadProgress)}%</span>
|
||||
</div>
|
||||
<Progress value={uploadProgress} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 sm:mt-6">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="mt-3 sm:mt-6">
|
||||
<SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Accordion
|
||||
type="single"
|
||||
|
|
@ -479,6 +448,6 @@ export function DocumentUploadTab({
|
|||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function AlertDialogOverlay({
|
|||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -45,7 +45,7 @@ function AlertDialogContent({
|
|||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -113,7 +113,7 @@ function AlertDialogCancel({
|
|||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
className={cn(buttonVariants({ variant: "secondary" }), className)}
|
||||
{...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",
|
||||
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_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(
|
||||
"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
|
||||
),
|
||||
outside: cn(
|
||||
|
|
@ -164,7 +167,7 @@ function CalendarDayButton({
|
|||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
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,
|
||||
className
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ function ContextMenuSubContent({
|
|||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -83,7 +83,7 @@ function ContextMenuContent({
|
|||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -107,8 +107,7 @@ function ContextMenuItem({
|
|||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
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",
|
||||
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -190,7 +189,7 @@ function ContextMenuSeparator({
|
|||
return (
|
||||
<ContextMenuPrimitive.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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
|||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{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" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ function DropdownMenuContent({
|
|||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -61,7 +61,7 @@ function DropdownMenuItem({
|
|||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -149,7 +149,7 @@ function DropdownMenuSeparator({
|
|||
return (
|
||||
<DropdownMenuPrimitive.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}
|
||||
/>
|
||||
);
|
||||
|
|
@ -201,7 +201,7 @@ function DropdownMenuSubContent({
|
|||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import {
|
|||
} from "lucide-react";
|
||||
import { KEYS } from "platejs";
|
||||
import { useEditorReadOnly, useEditorRef } from "platejs/react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useEditorSave } from "@/components/editor/editor-save-context";
|
||||
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
|
||||
|
||||
|
|
@ -26,11 +26,20 @@ import { ModeToolbarButton } from "./mode-toolbar-button";
|
|||
import { ToolbarButton, ToolbarGroup } from "./toolbar";
|
||||
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() {
|
||||
const readOnly = useEditorReadOnly();
|
||||
const editor = useEditorRef();
|
||||
const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave();
|
||||
const { shortcut } = usePlatformShortcut();
|
||||
const { shortcutKeys } = usePlatformShortcut();
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center">
|
||||
|
|
@ -40,7 +49,7 @@ export function FixedToolbarButtons() {
|
|||
<>
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
tooltip={`Undo ${shortcut("Mod", "Z")}`}
|
||||
tooltip={<TooltipWithShortcut label="Undo" keys={shortcutKeys("Mod", "Z")} />}
|
||||
onClick={() => {
|
||||
editor.undo();
|
||||
editor.tf.focus();
|
||||
|
|
@ -50,7 +59,9 @@ export function FixedToolbarButtons() {
|
|||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
tooltip={`Redo ${shortcut("Mod", "Shift", "Z")}`}
|
||||
tooltip={
|
||||
<TooltipWithShortcut label="Redo" keys={shortcutKeys("Mod", "Shift", "Z")} />
|
||||
}
|
||||
onClick={() => {
|
||||
editor.redo();
|
||||
editor.tf.focus();
|
||||
|
|
@ -66,35 +77,51 @@ export function FixedToolbarButtons() {
|
|||
</ToolbarGroup>
|
||||
|
||||
<ToolbarGroup>
|
||||
<MarkToolbarButton nodeType={KEYS.bold} tooltip={`Bold ${shortcut("Mod", "B")}`}>
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.bold}
|
||||
tooltip={<TooltipWithShortcut label="Bold" keys={shortcutKeys("Mod", "B")} />}
|
||||
>
|
||||
<BoldIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.italic} tooltip={`Italic ${shortcut("Mod", "I")}`}>
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.italic}
|
||||
tooltip={<TooltipWithShortcut label="Italic" keys={shortcutKeys("Mod", "I")} />}
|
||||
>
|
||||
<ItalicIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.underline}
|
||||
tooltip={`Underline ${shortcut("Mod", "U")}`}
|
||||
tooltip={<TooltipWithShortcut label="Underline" keys={shortcutKeys("Mod", "U")} />}
|
||||
>
|
||||
<UnderlineIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.strikethrough}
|
||||
tooltip={`Strikethrough ${shortcut("Mod", "Shift", "X")}`}
|
||||
tooltip={
|
||||
<TooltipWithShortcut
|
||||
label="Strikethrough"
|
||||
keys={shortcutKeys("Mod", "Shift", "X")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<StrikethroughIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.code} tooltip={`Code ${shortcut("Mod", "E")}`}>
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.code}
|
||||
tooltip={<TooltipWithShortcut label="Code" keys={shortcutKeys("Mod", "E")} />}
|
||||
>
|
||||
<Code2Icon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.highlight}
|
||||
tooltip={`Highlight ${shortcut("Mod", "Shift", "H")}`}
|
||||
tooltip={
|
||||
<TooltipWithShortcut label="Highlight" keys={shortcutKeys("Mod", "Shift", "H")} />
|
||||
}
|
||||
>
|
||||
<HighlighterIcon />
|
||||
</MarkToolbarButton>
|
||||
|
|
@ -113,7 +140,13 @@ export function FixedToolbarButtons() {
|
|||
{!readOnly && onSave && hasUnsavedChanges && (
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
tooltip={isSaving ? "Saving..." : `Save ${shortcut("Mod", "S")}`}
|
||||
tooltip={
|
||||
isSaving ? (
|
||||
"Saving..."
|
||||
) : (
|
||||
<TooltipWithShortcut label="Save" keys={shortcutKeys("Mod", "S")} />
|
||||
)
|
||||
}
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ function PopoverContent({
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ function SelectContent({
|
|||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
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" &&
|
||||
"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
|
||||
|
|
@ -88,18 +88,28 @@ function SelectLabel({ className, ...props }: React.ComponentProps<typeof Select
|
|||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
description,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item> & {
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
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
|
||||
)}
|
||||
{...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>
|
||||
);
|
||||
}
|
||||
|
|
@ -111,7 +121,7 @@ function SelectSeparator({
|
|||
return (
|
||||
<SelectPrimitive.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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
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"
|
||||
sideOffset={sideOffset}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -92,11 +92,16 @@ export const surfsenseDocsDocumentWithChunks = surfsenseDocsDocument.extend({
|
|||
/**
|
||||
* Get documents
|
||||
*/
|
||||
export const documentSortByEnum = z.enum(["created_at", "title", "document_type"]);
|
||||
export const sortOrderEnum = z.enum(["asc", "desc"]);
|
||||
|
||||
export const getDocumentsRequest = z.object({
|
||||
queryParams: paginationQueryParams
|
||||
.extend({
|
||||
search_space_id: z.number().or(z.string()).optional(),
|
||||
document_types: z.array(documentTypeEnum).optional(),
|
||||
sort_by: documentSortByEnum.optional(),
|
||||
sort_order: sortOrderEnum.optional(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
|
@ -311,6 +316,8 @@ export type UpdateDocumentResponse = z.infer<typeof updateDocumentResponse>;
|
|||
export type DeleteDocumentRequest = z.infer<typeof deleteDocumentRequest>;
|
||||
export type DeleteDocumentResponse = z.infer<typeof deleteDocumentResponse>;
|
||||
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 SurfsenseDocsDocument = z.infer<typeof surfsenseDocsDocument>;
|
||||
export type SurfsenseDocsDocumentWithChunks = z.infer<typeof surfsenseDocsDocumentWithChunks>;
|
||||
|
|
|
|||
|
|
@ -197,6 +197,12 @@ export const pageLimitExceededInboxItem = inboxItem.extend({
|
|||
// 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
|
||||
*/
|
||||
|
|
@ -204,6 +210,9 @@ export const getNotificationsRequest = z.object({
|
|||
queryParams: z.object({
|
||||
search_space_id: z.number().optional(),
|
||||
type: inboxItemTypeEnum.optional(),
|
||||
category: notificationCategory.optional(),
|
||||
source_type: z.string().optional(),
|
||||
filter: z.enum(["unread", "errors"]).optional(),
|
||||
before_date: z.string().optional(),
|
||||
search: z.string().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)
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
// =============================================================================
|
||||
|
|
@ -387,3 +410,5 @@ export type MarkNotificationReadResponse = z.infer<typeof markNotificationReadRe
|
|||
export type MarkAllNotificationsReadResponse = z.infer<typeof markAllNotificationsReadResponse>;
|
||||
export type GetUnreadCountRequest = z.infer<typeof getUnreadCountRequest>;
|
||||
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 replacements = [
|
||||
[
|
||||
"__NEXT_PUBLIC_FASTAPI_BACKEND_URL__",
|
||||
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_FASTAPI_BACKEND_AUTH_TYPE__",
|
||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "LOCAL",
|
||||
],
|
||||
[
|
||||
"__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",
|
||||
],
|
||||
[
|
||||
"__NEXT_PUBLIC_FASTAPI_BACKEND_URL__",
|
||||
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_FASTAPI_BACKEND_AUTH_TYPE__",
|
||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "LOCAL",
|
||||
],
|
||||
["__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 filesModified = 0;
|
||||
|
||||
function walk(dir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
} else if (entry.name.endsWith(".js")) {
|
||||
filesProcessed++;
|
||||
let content = fs.readFileSync(full, "utf8");
|
||||
let changed = false;
|
||||
for (const [placeholder, value] of replacements) {
|
||||
if (content.includes(placeholder)) {
|
||||
content = content.replaceAll(placeholder, value);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
fs.writeFileSync(full, content);
|
||||
filesModified++;
|
||||
}
|
||||
}
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
} else if (entry.name.endsWith(".js")) {
|
||||
filesProcessed++;
|
||||
let content = fs.readFileSync(full, "utf8");
|
||||
let changed = false;
|
||||
for (const [placeholder, value] of replacements) {
|
||||
if (content.includes(placeholder)) {
|
||||
content = content.replaceAll(placeholder, value);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
fs.writeFileSync(full, content);
|
||||
filesModified++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[entrypoint] Replacing environment variable placeholders...");
|
||||
for (const [placeholder, value] of replacements) {
|
||||
console.log(` ${placeholder} -> ${value}`);
|
||||
console.log(` ${placeholder} -> ${value}`);
|
||||
}
|
||||
|
||||
walk(path.join(__dirname, ".next"));
|
||||
|
||||
const serverJs = path.join(__dirname, "server.js");
|
||||
if (fs.existsSync(serverJs)) {
|
||||
let content = fs.readFileSync(serverJs, "utf8");
|
||||
let changed = false;
|
||||
filesProcessed++;
|
||||
for (const [placeholder, value] of replacements) {
|
||||
if (content.includes(placeholder)) {
|
||||
content = content.replaceAll(placeholder, value);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
fs.writeFileSync(serverJs, content);
|
||||
filesModified++;
|
||||
}
|
||||
let content = fs.readFileSync(serverJs, "utf8");
|
||||
let changed = false;
|
||||
filesProcessed++;
|
||||
for (const [placeholder, value] of replacements) {
|
||||
if (content.includes(placeholder)) {
|
||||
content = content.replaceAll(placeholder, value);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
fs.writeFileSync(serverJs, content);
|
||||
filesModified++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[entrypoint] Done. Scanned ${filesProcessed} files, modified ${filesModified}.`
|
||||
);
|
||||
console.log(`[entrypoint] Done. Scanned ${filesProcessed} files, modified ${filesModified}.`);
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ export function useComments({ messageId, enabled = true }: UseCommentsOptions) {
|
|||
|
||||
if (_batchInflight && _batchTargetIds.has(messageId)) {
|
||||
await _batchInflight;
|
||||
const cached = queryClient.getQueryData<GetCommentsResponse>(cacheKeys.comments.byMessage(messageId));
|
||||
const cached = queryClient.getQueryData<GetCommentsResponse>(
|
||||
cacheKeys.comments.byMessage(messageId)
|
||||
);
|
||||
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";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { DocumentSortBy, DocumentTypeEnum, SortOrder } from "@/contracts/types/document.types";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { filterNewElectricItems, getNewestTimestamp } from "@/lib/electric/baseline";
|
||||
import type { SyncHandle } from "@/lib/electric/client";
|
||||
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 {
|
||||
state: "ready" | "pending" | "processing" | "failed";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// Document from Electric sync (lightweight table columns - NO content/metadata)
|
||||
interface DocumentElectric {
|
||||
id: number;
|
||||
search_space_id: number;
|
||||
|
|
@ -27,7 +22,6 @@ interface DocumentElectric {
|
|||
status: DocumentStatusType | null;
|
||||
}
|
||||
|
||||
// Document for display (with resolved user name and email)
|
||||
export interface DocumentDisplay {
|
||||
id: number;
|
||||
search_space_id: number;
|
||||
|
|
@ -40,87 +34,86 @@ export interface DocumentDisplay {
|
|||
status: DocumentStatusType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate by ID and sort by created_at descending (newest first)
|
||||
*/
|
||||
function deduplicateAndSort<T extends { id: number; created_at: string }>(items: T[]): T[] {
|
||||
const seen = new Map<number, T>();
|
||||
for (const item of items) {
|
||||
// Keep the most recent version if duplicate
|
||||
const existing = seen.get(item.id);
|
||||
if (!existing || new Date(item.created_at) > new Date(existing.created_at)) {
|
||||
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()
|
||||
);
|
||||
export interface ApiDocumentInput {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a document has valid/complete data
|
||||
*/
|
||||
export function toDisplayDoc(item: ApiDocumentInput): DocumentDisplay {
|
||||
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 {
|
||||
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):
|
||||
* 1. API is the PRIMARY source of truth - always loads first
|
||||
* 2. Electric provides REAL-TIME updates for additions and deletions
|
||||
* 3. Use syncHandle.isUpToDate to determine if deletions can be trusted
|
||||
* 4. Handles bulk deletions correctly by checking sync state
|
||||
* Architecture:
|
||||
* 1. API is the PRIMARY data source — fetches pages on demand
|
||||
* 2. Type counts come from a dedicated lightweight API endpoint
|
||||
* 3. Electric provides REAL-TIME updates (new docs, deletions, status changes)
|
||||
* 4. Server-side sorting via sort_by + sort_order params
|
||||
*
|
||||
* Filtering strategy:
|
||||
* - Internal state always stores ALL documents (unfiltered)
|
||||
* - typeFilter is applied client-side when returning documents
|
||||
* - typeCounts always reflect the full dataset so the filter sidebar stays complete
|
||||
* - 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)
|
||||
* @param searchSpaceId - The search space to load documents for
|
||||
* @param typeFilter - Document types to filter by (server-side)
|
||||
* @param sortBy - Column to sort by (server-side)
|
||||
* @param sortOrder - Sort direction (server-side)
|
||||
*/
|
||||
export function useDocuments(
|
||||
searchSpaceId: number | null,
|
||||
typeFilter: DocumentTypeEnum[] = EMPTY_TYPE_FILTER
|
||||
typeFilter: DocumentTypeEnum[] = EMPTY_TYPE_FILTER,
|
||||
sortBy: DocumentSortBy = "created_at",
|
||||
sortOrder: SortOrder = "desc"
|
||||
) {
|
||||
const electricClient = useElectricClient();
|
||||
|
||||
// Internal state: ALL documents (unfiltered)
|
||||
const [allDocuments, setAllDocuments] = useState<DocumentDisplay[]>([]);
|
||||
const [documents, setDocuments] = useState<DocumentDisplay[]>([]);
|
||||
const [typeCounts, setTypeCounts] = useState<Record<string, number>>({});
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Track if initial API load is complete (source of truth)
|
||||
const apiLoadedRef = useRef(false);
|
||||
|
||||
// User cache: userId → displayName / email
|
||||
const apiLoadedCountRef = useRef(0);
|
||||
const initialLoadDoneRef = useRef(false);
|
||||
const prevParamsRef = useRef<{ sortBy: string; sortOrder: string; typeFilterKey: string } | null>(
|
||||
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 emailCacheRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Electric sync refs
|
||||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
||||
const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
|
||||
|
||||
// Type counts from ALL documents (unfiltered) — keeps filter sidebar complete
|
||||
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]);
|
||||
const typeFilterKey = typeFilter.join(",");
|
||||
|
||||
// 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(
|
||||
(
|
||||
items: Array<{
|
||||
|
|
@ -143,33 +136,11 @@ export function useDocuments(
|
|||
[]
|
||||
);
|
||||
|
||||
// Convert API item to display doc
|
||||
const apiToDisplayDoc = useCallback(
|
||||
(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" },
|
||||
}),
|
||||
(item: ApiDocumentInput): DocumentDisplay => toDisplayDoc(item),
|
||||
[]
|
||||
);
|
||||
|
||||
// Convert Electric doc to display doc
|
||||
const electricToDisplayDoc = useCallback(
|
||||
(doc: DocumentElectric): DocumentDisplay => ({
|
||||
...doc,
|
||||
|
|
@ -184,66 +155,91 @@ export function useDocuments(
|
|||
[]
|
||||
);
|
||||
|
||||
// STEP 1: Load ALL documents from API (PRIMARY source of truth).
|
||||
// Uses React Query for automatic deduplication, caching, and staleTime so
|
||||
// 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)
|
||||
// EFFECT 1: Fetch first page + type counts when params change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: typeFilterKey serializes typeFilter
|
||||
useEffect(() => {
|
||||
if (!apiResponse) return;
|
||||
populateUserCache(apiResponse.items);
|
||||
const docs = apiResponse.items.map(apiToDisplayDoc);
|
||||
setAllDocuments(docs);
|
||||
apiLoadedRef.current = true;
|
||||
setError(null);
|
||||
}, [apiResponse, populateUserCache, apiToDisplayDoc]);
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
// Propagate loading / error from React Query
|
||||
useEffect(() => {
|
||||
setLoading(apiLoading);
|
||||
}, [apiLoading]);
|
||||
let cancelled = false;
|
||||
|
||||
useEffect(() => {
|
||||
if (apiError) {
|
||||
setError(apiError instanceof Error ? apiError : new Error("Failed to load documents"));
|
||||
const prev = prevParamsRef.current;
|
||||
const isSortOnlyChange =
|
||||
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
|
||||
// No type filter — syncs and queries ALL documents; filtering is client-side
|
||||
const fetchInitialData = async () => {
|
||||
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(() => {
|
||||
if (!searchSpaceId || !electricClient) return;
|
||||
|
||||
// Capture validated values for async closure
|
||||
const spaceId = searchSpaceId;
|
||||
const client = electricClient;
|
||||
|
||||
let mounted = true;
|
||||
|
||||
async function setupElectricRealtime() {
|
||||
// Cleanup previous subscriptions
|
||||
if (syncHandleRef.current) {
|
||||
try {
|
||||
syncHandleRef.current.unsubscribe();
|
||||
} catch {
|
||||
// PGlite may already be closed during cleanup
|
||||
/* PGlite may already be closed */
|
||||
}
|
||||
syncHandleRef.current = null;
|
||||
}
|
||||
|
|
@ -251,15 +247,12 @@ export function useDocuments(
|
|||
try {
|
||||
liveQueryRef.current.unsubscribe?.();
|
||||
} catch {
|
||||
// PGlite may already be closed during cleanup
|
||||
/* PGlite may already be closed */
|
||||
}
|
||||
liveQueryRef.current = null;
|
||||
}
|
||||
|
||||
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({
|
||||
table: "documents",
|
||||
where: `search_space_id = ${spaceId}`,
|
||||
|
|
@ -281,20 +274,16 @@ export function useDocuments(
|
|||
}
|
||||
|
||||
syncHandleRef.current = handle;
|
||||
console.log("[useDocuments] Sync started, isUpToDate:", handle.isUpToDate);
|
||||
|
||||
// Wait for initial sync (with timeout)
|
||||
if (!handle.isUpToDate && handle.initialSyncPromise) {
|
||||
await Promise.race([
|
||||
handle.initialSyncPromise,
|
||||
new Promise((resolve) => setTimeout(resolve, 5000)),
|
||||
]);
|
||||
console.log("[useDocuments] Initial sync complete, isUpToDate:", handle.isUpToDate);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Set up live query (unfiltered — type filtering is done client-side)
|
||||
const db = client.db as {
|
||||
live?: {
|
||||
query: <T>(
|
||||
|
|
@ -307,13 +296,10 @@ export function useDocuments(
|
|||
};
|
||||
};
|
||||
|
||||
if (!db.live?.query) {
|
||||
console.warn("[useDocuments] Live queries not available");
|
||||
return;
|
||||
}
|
||||
if (!db.live?.query) return;
|
||||
|
||||
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
|
||||
ORDER BY created_at DESC`;
|
||||
|
||||
|
|
@ -324,22 +310,12 @@ export function useDocuments(
|
|||
return;
|
||||
}
|
||||
|
||||
console.log("[useDocuments] Live query subscribed");
|
||||
|
||||
liveQuery.subscribe((result: { rows: DocumentElectric[] }) => {
|
||||
if (!mounted || !result.rows) 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));
|
||||
if (!mounted || !result.rows || !initialLoadDoneRef.current) return;
|
||||
|
||||
const validItems = result.rows.filter(isValidDocument);
|
||||
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
|
||||
.filter(
|
||||
(doc): doc is DocumentElectric & { created_by_id: string } =>
|
||||
|
|
@ -350,12 +326,16 @@ export function useDocuments(
|
|||
if (unknownUserIds.length > 0) {
|
||||
documentsApiService
|
||||
.getDocuments({
|
||||
queryParams: { search_space_id: spaceId, page: 0, page_size: 20 },
|
||||
queryParams: {
|
||||
search_space_id: spaceId,
|
||||
page: 0,
|
||||
page_size: 20,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
populateUserCache(response.items);
|
||||
if (mounted) {
|
||||
setAllDocuments((prev) =>
|
||||
setDocuments((prev) =>
|
||||
prev.map((doc) => ({
|
||||
...doc,
|
||||
created_by_name: doc.created_by_id
|
||||
|
|
@ -371,46 +351,20 @@ export function useDocuments(
|
|||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Smart update logic based on sync state
|
||||
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));
|
||||
setDocuments((prev) => {
|
||||
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 = validItems
|
||||
.filter((item) => !existingIds.has(item.id))
|
||||
.map(electricToDisplayDoc);
|
||||
const newItems = filterNewElectricItems(
|
||||
validItems,
|
||||
liveIds,
|
||||
prevIds,
|
||||
electricBaselineIdsRef,
|
||||
newestApiTimestampRef.current
|
||||
).map(electricToDisplayDoc);
|
||||
|
||||
// Find updated items (in both, update with latest data)
|
||||
const updatedPrev = prev.map((doc) => {
|
||||
// Update existing docs (status changes, title edits)
|
||||
let updated = prev.map((doc) => {
|
||||
if (liveIds.has(doc.id)) {
|
||||
const liveItem = validItems.find((v) => v.id === doc.id);
|
||||
if (liveItem) {
|
||||
|
|
@ -420,19 +374,32 @@ export function useDocuments(
|
|||
return doc;
|
||||
});
|
||||
|
||||
if (newItems.length > 0) {
|
||||
console.log(`[useDocuments] Adding ${newItems.length} new items (partial sync)`);
|
||||
return deduplicateAndSort([...newItems, ...updatedPrev]);
|
||||
// Remove deleted docs (only when fully synced)
|
||||
if (isFullySynced) {
|
||||
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;
|
||||
} catch (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 {
|
||||
syncHandleRef.current.unsubscribe();
|
||||
} catch {
|
||||
// PGlite may already be closed during cleanup
|
||||
/* PGlite may already be closed */
|
||||
}
|
||||
syncHandleRef.current = null;
|
||||
}
|
||||
|
|
@ -452,32 +419,85 @@ export function useDocuments(
|
|||
try {
|
||||
liveQueryRef.current.unsubscribe?.();
|
||||
} catch {
|
||||
// PGlite may already be closed during cleanup
|
||||
/* PGlite may already be closed */
|
||||
}
|
||||
liveQueryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [searchSpaceId, electricClient, electricToDisplayDoc, populateUserCache]);
|
||||
|
||||
// Track previous searchSpaceId to detect actual changes
|
||||
// Reset on search space change
|
||||
const prevSearchSpaceIdRef = useRef<number | null>(null);
|
||||
|
||||
// Reset on search space change (not on initial mount)
|
||||
useEffect(() => {
|
||||
if (prevSearchSpaceIdRef.current !== null && prevSearchSpaceIdRef.current !== searchSpaceId) {
|
||||
setAllDocuments([]);
|
||||
apiLoadedRef.current = false;
|
||||
setDocuments([]);
|
||||
setTypeCounts({});
|
||||
setTotal(0);
|
||||
setHasMore(false);
|
||||
apiLoadedCountRef.current = 0;
|
||||
initialLoadDoneRef.current = false;
|
||||
electricBaselineIdsRef.current = null;
|
||||
newestApiTimestampRef.current = null;
|
||||
userCacheRef.current.clear();
|
||||
emailCacheRef.current.clear();
|
||||
}
|
||||
prevSearchSpaceIdRef.current = 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 {
|
||||
documents,
|
||||
typeCounts,
|
||||
total: documents.length,
|
||||
total,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,497 +1,402 @@
|
|||
"use client";
|
||||
|
||||
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 type { SyncHandle } from "@/lib/electric/client";
|
||||
import { filterNewElectricItems, getNewestTimestamp } from "@/lib/electric/baseline";
|
||||
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 SYNC_WINDOW_DAYS = 14;
|
||||
const INITIAL_PAGE_SIZE = 50;
|
||||
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
|
||||
*/
|
||||
function isOlderThanSyncWindow(createdAt: string): boolean {
|
||||
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!
|
||||
* Calculate the cutoff date for sync window.
|
||||
* Rounds to the start of the day (midnight UTC) to ensure stable values
|
||||
* across re-renders.
|
||||
*/
|
||||
function getSyncCutoffDate(): string {
|
||||
const cutoff = new Date();
|
||||
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);
|
||||
return cutoff.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a date value to ISO string format
|
||||
*/
|
||||
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
|
||||
* Hook for managing inbox items with API-first architecture + Electric real-time deltas.
|
||||
*
|
||||
* Architecture (Simplified & Race-Condition Free):
|
||||
* - Electric SQL: Syncs recent items (within SYNC_WINDOW_DAYS) for real-time updates
|
||||
* - Live Query: Provides reactive first page from PGLite
|
||||
* - API: Handles all pagination (more reliable than mixing with Electric)
|
||||
* Architecture (Documents pattern, per-tab):
|
||||
* 1. API is the PRIMARY data source — fetches first page on mount with category filter
|
||||
* 2. Electric provides REAL-TIME updates (new items, status changes, read state)
|
||||
* 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:
|
||||
* 1. No mutable refs for cursor - cursor computed from current state
|
||||
* 2. Single deduplicateAndSort function - prevents inconsistencies
|
||||
* 3. Filter-based preservation in live query - prevents data loss
|
||||
* 4. Auto-fetch from API when Electric returns 0 items
|
||||
* Unread count strategy:
|
||||
* - API provides the category-filtered total on mount (ground truth across all time)
|
||||
* - Electric live query counts unread within SYNC_WINDOW_DAYS (filtered by type)
|
||||
* - olderUnreadOffsetRef bridges the gap: total = offset + recent
|
||||
* - Optimistic updates adjust both the count and the offset (for old items)
|
||||
*
|
||||
* @param userId - The user ID to fetch inbox items for
|
||||
* @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(
|
||||
userId: string | null,
|
||||
searchSpaceId: number | null,
|
||||
typeFilter: InboxItemTypeEnum | null = null
|
||||
category: NotificationCategory
|
||||
) {
|
||||
const electricClient = useElectricClient();
|
||||
|
||||
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
// Split unread count tracking for accurate counts with 14-day sync window
|
||||
// olderUnreadCount = unread items OLDER than sync window (from server, static until reconciliation)
|
||||
// recentUnreadCount = unread items within sync window (from live query, real-time)
|
||||
const [olderUnreadCount, setOlderUnreadCount] = useState(0);
|
||||
const [recentUnreadCount, setRecentUnreadCount] = useState(0);
|
||||
const initialLoadDoneRef = useRef(false);
|
||||
const electricBaselineIdsRef = useRef<Set<number> | null>(null);
|
||||
const newestApiTimestampRef = useRef<string | null>(null);
|
||||
const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
|
||||
const unreadLiveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
|
||||
|
||||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
||||
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
||||
const userSyncKeyRef = useRef<string | null>(null);
|
||||
const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
||||
const olderUnreadOffsetRef = useRef<number | null>(null);
|
||||
const apiUnreadTotalRef = useRef(0);
|
||||
|
||||
// Total unread = older (static from server) + recent (live from Electric)
|
||||
const totalUnreadCount = olderUnreadCount + recentUnreadCount;
|
||||
|
||||
// EFFECT 1: Electric SQL sync for real-time updates
|
||||
// EFFECT 1: Fetch first page + unread count from API with category filter
|
||||
useEffect(() => {
|
||||
if (!userId || !electricClient) {
|
||||
setLoading(!electricClient);
|
||||
return;
|
||||
}
|
||||
if (!userId || !searchSpaceId) 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 typeFilter = CATEGORY_TYPE_SQL[category];
|
||||
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 {
|
||||
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({
|
||||
table: "notifications",
|
||||
where: `user_id = '${userId}' AND created_at > '${cutoffDate}'`,
|
||||
where: `user_id = '${uid}' AND created_at > '${cutoffDate}'`,
|
||||
primaryKey: ["id"],
|
||||
});
|
||||
|
||||
// Wait for initial sync with timeout
|
||||
if (!mounted) return;
|
||||
|
||||
if (!handle.isUpToDate && handle.initialSyncPromise) {
|
||||
await Promise.race([
|
||||
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) {
|
||||
handle.unsubscribe();
|
||||
liveQuery.unsubscribe?.();
|
||||
return;
|
||||
}
|
||||
|
||||
syncHandleRef.current = handle;
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
console.error("[useInbox] Sync failed:", err);
|
||||
setError(err instanceof Error ? err : new Error("Sync failed"));
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
liveQuery.subscribe((result: { rows: InboxItem[] }) => {
|
||||
if (!mounted || !result.rows || !initialLoadDoneRef.current) return;
|
||||
|
||||
startSync();
|
||||
const validItems = result.rows.filter((item) => item.id != null && item.title != null);
|
||||
const cutoff = new Date(getSyncCutoffDate());
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
userSyncKeyRef.current = null;
|
||||
if (syncHandleRef.current) {
|
||||
try {
|
||||
syncHandleRef.current.unsubscribe();
|
||||
} catch {
|
||||
// PGlite may already be closed during cleanup
|
||||
}
|
||||
syncHandleRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, electricClient]);
|
||||
const liveItemMap = new Map(validItems.map((d) => [d.id, d]));
|
||||
const liveIds = new Set(liveItemMap.keys());
|
||||
|
||||
// Reset when filters change
|
||||
useEffect(() => {
|
||||
setHasMore(true);
|
||||
setInboxItems([]);
|
||||
// Reset count states - will be refetched by the unread count effect
|
||||
setOlderUnreadCount(0);
|
||||
setRecentUnreadCount(0);
|
||||
}, [userId, searchSpaceId, typeFilter]);
|
||||
setInboxItems((prev) => {
|
||||
const prevIds = new Set(prev.map((d) => d.id));
|
||||
|
||||
// EFFECT 2: Live query for real-time updates + auto-fetch from API if empty
|
||||
useEffect(() => {
|
||||
if (!userId || !electricClient) return;
|
||||
|
||||
const client = electricClient;
|
||||
let mounted = true;
|
||||
|
||||
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"
|
||||
const newItems = filterNewElectricItems(
|
||||
validItems,
|
||||
liveIds,
|
||||
prevIds,
|
||||
electricBaselineIdsRef,
|
||||
newestApiTimestampRef.current
|
||||
);
|
||||
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) {
|
||||
if (data.items.length > 0) {
|
||||
setInboxItems(data.items);
|
||||
}
|
||||
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]);
|
||||
});
|
||||
}
|
||||
let updated = prev.map((item) => {
|
||||
const liveItem = liveItemMap.get(item.id);
|
||||
if (liveItem) return liveItem;
|
||||
return item;
|
||||
});
|
||||
|
||||
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) {
|
||||
liveQueryRef.current = liveQuery;
|
||||
// Derive unread count from all Electric items + the older offset
|
||||
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) {
|
||||
console.error("[useInbox] Live query error:", err);
|
||||
console.error(`[useInbox:${category}] Electric setup failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
setupLiveQuery();
|
||||
setupElectricRealtime();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
// Only clean up live queries — sync shape is shared across instances
|
||||
if (liveQueryRef.current) {
|
||||
try {
|
||||
liveQueryRef.current.unsubscribe();
|
||||
liveQueryRef.current.unsubscribe?.();
|
||||
} catch {
|
||||
// PGlite may already be closed during cleanup
|
||||
/* PGlite may be closed */
|
||||
}
|
||||
liveQueryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, searchSpaceId, typeFilter, electricClient]);
|
||||
|
||||
// EFFECT 3: Dedicated unread count sync with split tracking
|
||||
// - Fetches server count on mount (accurate total)
|
||||
// - 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}`
|
||||
);
|
||||
if (unreadLiveQueryRef.current) {
|
||||
try {
|
||||
unreadLiveQueryRef.current.unsubscribe?.();
|
||||
} catch {
|
||||
/* PGlite may be closed */
|
||||
}
|
||||
|
||||
// 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;
|
||||
unreadLiveQueryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [userId, searchSpaceId, typeFilter, electricClient]);
|
||||
}, [userId, searchSpaceId, electricClient, category]);
|
||||
|
||||
// loadMore - Pure cursor-based pagination, no race conditions
|
||||
// Cursor is computed from current state, not stored in refs
|
||||
// Load more pages via API (cursor-based using before_date)
|
||||
const loadMore = useCallback(async () => {
|
||||
// Removed inboxItems.length === 0 check to allow loading older items
|
||||
// when Electric returns 0 items
|
||||
if (!userId || loadingMore || !hasMore) return;
|
||||
if (loadingMore || !hasMore || !userId || !searchSpaceId) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
|
||||
try {
|
||||
// Cursor is computed from current state - no stale refs possible
|
||||
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)");
|
||||
|
||||
// Use the API service with proper Zod validation
|
||||
const data = await notificationsApiService.getNotifications({
|
||||
const response = await notificationsApiService.getNotifications({
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId ?? undefined,
|
||||
type: typeFilter ?? undefined,
|
||||
before_date: beforeDate ?? undefined,
|
||||
limit: PAGE_SIZE,
|
||||
search_space_id: searchSpaceId,
|
||||
category,
|
||||
before_date: beforeDate,
|
||||
limit: SCROLL_PAGE_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.items.length > 0) {
|
||||
// Functional update ensures we always merge with latest state
|
||||
// Items are already validated by the API service
|
||||
setInboxItems((prev) => deduplicateAndSort([...prev, ...data.items]));
|
||||
}
|
||||
const newItems = response.items;
|
||||
|
||||
// Use API's has_more flag
|
||||
setHasMore(data.has_more);
|
||||
setInboxItems((prev) => {
|
||||
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) {
|
||||
console.error("[useInbox] Load more failed:", err);
|
||||
console.error(`[useInbox:${category}] Load more failed:`, err);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]);
|
||||
}, [loadingMore, hasMore, userId, searchSpaceId, inboxItems, category]);
|
||||
|
||||
// Mark inbox item as read with optimistic update
|
||||
// Handles both recent items (live query updates count) and older items (manual count decrement)
|
||||
// Mark single item as read with optimistic update
|
||||
const markAsRead = useCallback(
|
||||
async (itemId: number) => {
|
||||
// Find the item to check if it's older than sync window
|
||||
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)));
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
|
||||
// If older item, manually decrement older count
|
||||
// (live query won't see items outside sync window)
|
||||
if (isOlderItem) {
|
||||
setOlderUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
if (isOlderItem && olderUnreadOffsetRef.current !== null) {
|
||||
olderUnreadOffsetRef.current = Math.max(0, olderUnreadOffsetRef.current - 1);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the API service with proper Zod validation
|
||||
const result = await notificationsApiService.markAsRead({ notificationId: itemId });
|
||||
|
||||
if (!result.success) {
|
||||
// Rollback on error
|
||||
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
|
||||
if (isOlderItem) {
|
||||
setOlderUnreadCount((prev) => prev + 1);
|
||||
setUnreadCount((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;
|
||||
} catch (err) {
|
||||
console.error("Failed to mark as read:", err);
|
||||
// Rollback on error
|
||||
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
|
||||
if (isOlderItem) {
|
||||
setOlderUnreadCount((prev) => prev + 1);
|
||||
setUnreadCount((prev) => prev + 1);
|
||||
if (isOlderItem && olderUnreadOffsetRef.current !== null) {
|
||||
olderUnreadOffsetRef.current += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -499,49 +404,42 @@ export function useInbox(
|
|||
[inboxItems]
|
||||
);
|
||||
|
||||
// Mark all inbox items as read with optimistic update
|
||||
// Resets both older and recent counts to 0
|
||||
// Mark all as read with optimistic update
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
// Store previous counts for potential rollback
|
||||
const prevOlderCount = olderUnreadCount;
|
||||
const prevRecentCount = recentUnreadCount;
|
||||
const prevItems = inboxItems;
|
||||
const prevCount = unreadCount;
|
||||
const prevOffset = olderUnreadOffsetRef.current;
|
||||
|
||||
// Optimistic update: mark all as read immediately for instant UI feedback
|
||||
setInboxItems((prev) => prev.map((item) => ({ ...item, read: true })));
|
||||
setOlderUnreadCount(0);
|
||||
setRecentUnreadCount(0);
|
||||
setUnreadCount(0);
|
||||
olderUnreadOffsetRef.current = 0;
|
||||
|
||||
try {
|
||||
// Use the API service with proper Zod validation
|
||||
const result = await notificationsApiService.markAllAsRead();
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Failed to mark all as read");
|
||||
// Rollback counts on error
|
||||
setOlderUnreadCount(prevOlderCount);
|
||||
setRecentUnreadCount(prevRecentCount);
|
||||
setInboxItems(prevItems);
|
||||
setUnreadCount(prevCount);
|
||||
olderUnreadOffsetRef.current = prevOffset;
|
||||
}
|
||||
// Electric SQL will sync and live query will ensure consistency
|
||||
return result.success;
|
||||
} catch (err) {
|
||||
console.error("Failed to mark all as read:", err);
|
||||
// Rollback counts on error
|
||||
setOlderUnreadCount(prevOlderCount);
|
||||
setRecentUnreadCount(prevRecentCount);
|
||||
setInboxItems(prevItems);
|
||||
setUnreadCount(prevCount);
|
||||
olderUnreadOffsetRef.current = prevOffset;
|
||||
return false;
|
||||
}
|
||||
}, [olderUnreadCount, recentUnreadCount]);
|
||||
}, [inboxItems, unreadCount]);
|
||||
|
||||
return {
|
||||
inboxItems,
|
||||
unreadCount: totalUnreadCount,
|
||||
unreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
isUsingApiFallback: true, // Always use API for pagination
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const shortcut = useCallback(
|
||||
(...keys: string[]) => {
|
||||
if (!ready) return "";
|
||||
|
||||
const resolveKeys = useCallback(
|
||||
(keys: string[]) => {
|
||||
const mod = isMac ? "⌘" : "Ctrl";
|
||||
const shift = isMac ? "⇧" : "Shift";
|
||||
const alt = isMac ? "⌥" : "Alt";
|
||||
|
||||
const mapped = keys.map((k) => {
|
||||
return keys.map((k) => {
|
||||
if (k === "Mod") return mod;
|
||||
if (k === "Shift") return shift;
|
||||
if (k === "Alt") return alt;
|
||||
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 {
|
||||
type GetNotificationsRequest,
|
||||
type GetNotificationsResponse,
|
||||
type GetSourceTypesResponse,
|
||||
type GetUnreadCountResponse,
|
||||
getNotificationsRequest,
|
||||
getNotificationsResponse,
|
||||
getSourceTypesResponse,
|
||||
getUnreadCountResponse,
|
||||
type InboxItemTypeEnum,
|
||||
type MarkAllNotificationsReadResponse,
|
||||
|
|
@ -12,6 +14,7 @@ import {
|
|||
markAllNotificationsReadResponse,
|
||||
markNotificationReadRequest,
|
||||
markNotificationReadResponse,
|
||||
type NotificationCategory,
|
||||
} from "@/contracts/types/inbox.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
|
@ -42,6 +45,15 @@ class NotificationsApiService {
|
|||
if (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) {
|
||||
params.append("before_date", queryParams.before_date);
|
||||
}
|
||||
|
|
@ -92,16 +104,33 @@ class NotificationsApiService {
|
|||
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
|
||||
* - total_unread: All unread notifications
|
||||
* - recent_unread: Unread within sync window (last 14 days)
|
||||
* @param searchSpaceId - Optional search space ID to filter by
|
||||
* @param type - Optional notification type to filter by (type-safe enum)
|
||||
* @param category - Optional category filter ('comments' or 'status')
|
||||
*/
|
||||
getUnreadCount = async (
|
||||
searchSpaceId?: number,
|
||||
type?: InboxItemTypeEnum
|
||||
type?: InboxItemTypeEnum,
|
||||
category?: NotificationCategory
|
||||
): Promise<GetUnreadCountResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (searchSpaceId !== undefined) {
|
||||
|
|
@ -110,6 +139,9 @@ class NotificationsApiService {
|
|||
if (type) {
|
||||
params.append("type", type);
|
||||
}
|
||||
if (category) {
|
||||
params.append("category", category);
|
||||
}
|
||||
const queryString = params.toString();
|
||||
|
||||
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,
|
||||
// consolidated single documents sync, pending state for document queue visibility
|
||||
// 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
|
||||
const DB_PREFIX = "surfsense-";
|
||||
|
|
|
|||
|
|
@ -96,5 +96,7 @@ export const cacheKeys = {
|
|||
notifications: {
|
||||
search: (searchSpaceId: number | null, search: string, tab: string) =>
|
||||
["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",
|
||||
"create_shared_note": "Create Shared Note",
|
||||
"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": {
|
||||
"title": "Connect Your Tools",
|
||||
|
|
@ -637,23 +638,6 @@
|
|||
"add_first_config": "Add First Configuration",
|
||||
"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": {
|
||||
"chats": "Private Chats",
|
||||
"shared_chats": "Shared Chats",
|
||||
|
|
@ -718,8 +702,11 @@
|
|||
"filter": "Filter",
|
||||
"all": "All",
|
||||
"unread": "Unread",
|
||||
"errors_only": "Errors only",
|
||||
"connectors": "Connectors",
|
||||
"all_connectors": "All connectors",
|
||||
"sources": "Sources",
|
||||
"all_sources": "All sources",
|
||||
"close": "Close",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -332,7 +332,8 @@
|
|||
"upload_documents": "Subir documentos",
|
||||
"create_shared_note": "Crear nota compartida",
|
||||
"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": {
|
||||
"title": "Conecta tus herramientas",
|
||||
|
|
@ -637,23 +638,6 @@
|
|||
"add_first_config": "Agregar primera configuración",
|
||||
"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": {
|
||||
"chats": "Chats privados",
|
||||
"shared_chats": "Chats compartidos",
|
||||
|
|
@ -718,8 +702,11 @@
|
|||
"filter": "Filtrar",
|
||||
"all": "Todo",
|
||||
"unread": "No leído",
|
||||
"errors_only": "Solo errores",
|
||||
"connectors": "Conectores",
|
||||
"all_connectors": "Todos los conectores",
|
||||
"sources": "Fuentes",
|
||||
"all_sources": "Todas las fuentes",
|
||||
"close": "Cerrar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -332,7 +332,8 @@
|
|||
"upload_documents": "दस्तावेज़ अपलोड करें",
|
||||
"create_shared_note": "साझा नोट बनाएं",
|
||||
"processing_documents": "दस्तावेज़ प्रोसेस हो रहे हैं...",
|
||||
"active_tasks_count": "{count} सक्रिय कार्य"
|
||||
"delete_in_progress_warning": "{count} दस्तावेज़ लंबित या प्रसंस्करण में हैं और हटाए नहीं जा सकते।",
|
||||
"delete_conflict_error": "{count} दस्तावेज़ प्रसंस्करण शुरू हो गया है। कृपया बाद में पुनः प्रयास करें।"
|
||||
},
|
||||
"add_connector": {
|
||||
"title": "अपने टूल कनेक्ट करें",
|
||||
|
|
@ -637,23 +638,6 @@
|
|||
"add_first_config": "पहली कॉन्फ़िगरेशन जोड़ें",
|
||||
"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": {
|
||||
"chats": "निजी चैट",
|
||||
"shared_chats": "साझा चैट",
|
||||
|
|
@ -718,8 +702,11 @@
|
|||
"filter": "फ़िल्टर",
|
||||
"all": "सभी",
|
||||
"unread": "अपठित",
|
||||
"errors_only": "केवल त्रुटियाँ",
|
||||
"connectors": "कनेक्टर",
|
||||
"all_connectors": "सभी कनेक्टर",
|
||||
"sources": "स्रोत",
|
||||
"all_sources": "सभी स्रोत",
|
||||
"close": "बंद करें",
|
||||
"cancel": "रद्द करें"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -332,7 +332,8 @@
|
|||
"upload_documents": "Enviar documentos",
|
||||
"create_shared_note": "Criar nota compartilhada",
|
||||
"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": {
|
||||
"title": "Conecte suas ferramentas",
|
||||
|
|
@ -637,23 +638,6 @@
|
|||
"add_first_config": "Adicionar primeira configuração",
|
||||
"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": {
|
||||
"chats": "Chats privados",
|
||||
"shared_chats": "Chats compartilhados",
|
||||
|
|
@ -718,8 +702,11 @@
|
|||
"filter": "Filtrar",
|
||||
"all": "Tudo",
|
||||
"unread": "Não lido",
|
||||
"errors_only": "Apenas erros",
|
||||
"connectors": "Conectores",
|
||||
"all_connectors": "Todos os conectores",
|
||||
"sources": "Fontes",
|
||||
"all_sources": "Todas as fontes",
|
||||
"close": "Fechar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -316,7 +316,8 @@
|
|||
"upload_documents": "上传文档",
|
||||
"create_shared_note": "创建共享笔记",
|
||||
"processing_documents": "正在处理文档...",
|
||||
"active_tasks_count": "{count} 个正在进行的工作项"
|
||||
"delete_in_progress_warning": "{count} 个文档正在等待或处理中,无法删除。",
|
||||
"delete_conflict_error": "{count} 个文档已开始处理,请稍后再试。"
|
||||
},
|
||||
"add_connector": {
|
||||
"title": "连接您的工具",
|
||||
|
|
@ -621,23 +622,6 @@
|
|||
"add_first_config": "添加首个配置",
|
||||
"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": {
|
||||
"chats": "私人对话",
|
||||
"shared_chats": "共享对话",
|
||||
|
|
@ -702,8 +686,11 @@
|
|||
"filter": "筛选",
|
||||
"all": "全部",
|
||||
"unread": "未读",
|
||||
"errors_only": "仅错误",
|
||||
"connectors": "连接器",
|
||||
"all_connectors": "所有连接器",
|
||||
"sources": "来源",
|
||||
"all_sources": "所有来源",
|
||||
"close": "关闭",
|
||||
"cancel": "取消"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@
|
|||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.1.2",
|
||||
"@biomejs/biome": "2.4.6",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@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))
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.1.2
|
||||
version: 2.1.2
|
||||
specifier: 2.4.6
|
||||
version: 2.4.6
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.3
|
||||
|
|
@ -1080,55 +1080,55 @@ packages:
|
|||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@biomejs/biome@2.1.2':
|
||||
resolution: {integrity: sha512-yq8ZZuKuBVDgAS76LWCfFKHSYIAgqkxVB3mGVVpOe2vSkUTs7xG46zXZeNPRNVjiJuw0SZ3+J2rXiYx0RUpfGg==}
|
||||
'@biomejs/biome@2.4.6':
|
||||
resolution: {integrity: sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
hasBin: true
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.1.2':
|
||||
resolution: {integrity: sha512-leFAks64PEIjc7MY/cLjE8u5OcfBKkcDB0szxsWUB4aDfemBep1WVKt0qrEyqZBOW8LPHzrFMyDl3FhuuA0E7g==}
|
||||
'@biomejs/cli-darwin-arm64@2.4.6':
|
||||
resolution: {integrity: sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.1.2':
|
||||
resolution: {integrity: sha512-Nmmv7wRX5Nj7lGmz0FjnWdflJg4zii8Ivruas6PBKzw5SJX/q+Zh2RfnO+bBnuKLXpj8kiI2x2X12otpH6a32A==}
|
||||
'@biomejs/cli-darwin-x64@2.4.6':
|
||||
resolution: {integrity: sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.1.2':
|
||||
resolution: {integrity: sha512-qgHvafhjH7Oca114FdOScmIKf1DlXT1LqbOrrbR30kQDLFPEOpBG0uzx6MhmsrmhGiCFCr2obDamu+czk+X0HQ==}
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.6':
|
||||
resolution: {integrity: sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.1.2':
|
||||
resolution: {integrity: sha512-NWNy2Diocav61HZiv2enTQykbPP/KrA/baS7JsLSojC7Xxh2nl9IczuvE5UID7+ksRy2e7yH7klm/WkA72G1dw==}
|
||||
'@biomejs/cli-linux-arm64@2.4.6':
|
||||
resolution: {integrity: sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.1.2':
|
||||
resolution: {integrity: sha512-xlB3mU14ZUa3wzLtXfmk2IMOGL+S0aHFhSix/nssWS/2XlD27q+S6f0dlQ8WOCbYoXcuz8BCM7rCn2lxdTrlQA==}
|
||||
'@biomejs/cli-linux-x64-musl@2.4.6':
|
||||
resolution: {integrity: sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.1.2':
|
||||
resolution: {integrity: sha512-Km/UYeVowygTjpX6sGBzlizjakLoMQkxWbruVZSNE6osuSI63i4uCeIL+6q2AJlD3dxoiBJX70dn1enjQnQqwA==}
|
||||
'@biomejs/cli-linux-x64@2.4.6':
|
||||
resolution: {integrity: sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.1.2':
|
||||
resolution: {integrity: sha512-G8KWZli5ASOXA3yUQgx+M4pZRv3ND16h77UsdunUL17uYpcL/UC7RkWTdkfvMQvogVsAuz5JUcBDjgZHXxlKoA==}
|
||||
'@biomejs/cli-win32-arm64@2.4.6':
|
||||
resolution: {integrity: sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@biomejs/cli-win32-x64@2.1.2':
|
||||
resolution: {integrity: sha512-9zajnk59PMpjBkty3bK2IrjUsUHvqe9HWwyAWQBjGLE7MIBjbX2vwv1XPEhmO2RRuGoTkVx3WCanHrjAytICLA==}
|
||||
'@biomejs/cli-win32-x64@2.4.6':
|
||||
resolution: {integrity: sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
|
@ -7971,39 +7971,39 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@biomejs/biome@2.1.2':
|
||||
'@biomejs/biome@2.4.6':
|
||||
optionalDependencies:
|
||||
'@biomejs/cli-darwin-arm64': 2.1.2
|
||||
'@biomejs/cli-darwin-x64': 2.1.2
|
||||
'@biomejs/cli-linux-arm64': 2.1.2
|
||||
'@biomejs/cli-linux-arm64-musl': 2.1.2
|
||||
'@biomejs/cli-linux-x64': 2.1.2
|
||||
'@biomejs/cli-linux-x64-musl': 2.1.2
|
||||
'@biomejs/cli-win32-arm64': 2.1.2
|
||||
'@biomejs/cli-win32-x64': 2.1.2
|
||||
'@biomejs/cli-darwin-arm64': 2.4.6
|
||||
'@biomejs/cli-darwin-x64': 2.4.6
|
||||
'@biomejs/cli-linux-arm64': 2.4.6
|
||||
'@biomejs/cli-linux-arm64-musl': 2.4.6
|
||||
'@biomejs/cli-linux-x64': 2.4.6
|
||||
'@biomejs/cli-linux-x64-musl': 2.4.6
|
||||
'@biomejs/cli-win32-arm64': 2.4.6
|
||||
'@biomejs/cli-win32-x64': 2.4.6
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.1.2':
|
||||
'@biomejs/cli-darwin-arm64@2.4.6':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.1.2':
|
||||
'@biomejs/cli-darwin-x64@2.4.6':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.1.2':
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.6':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.1.2':
|
||||
'@biomejs/cli-linux-arm64@2.4.6':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.1.2':
|
||||
'@biomejs/cli-linux-x64-musl@2.4.6':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64@2.1.2':
|
||||
'@biomejs/cli-linux-x64@2.4.6':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.1.2':
|
||||
'@biomejs/cli-win32-arm64@2.4.6':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-x64@2.1.2':
|
||||
'@biomejs/cli-win32-x64@2.4.6':
|
||||
optional: true
|
||||
|
||||
'@date-fns/tz@1.4.1': {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue