Merge remote-tracking branch 'upstream/dev' into fix/github-and-ui-fixes

This commit is contained in:
Anish Sarkar 2026-03-08 16:57:10 +05:30
commit 701bb7063f
89 changed files with 3627 additions and 3951 deletions

View file

@ -320,6 +320,8 @@ async def read_documents(
page_size: int = 50, page_size: int = 50,
search_space_id: int | None = None, search_space_id: int | None = None,
document_types: str | None = None, document_types: str | None = None,
sort_by: str = "created_at",
sort_order: str = "desc",
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
@ -392,6 +394,19 @@ async def read_documents(
total_result = await session.execute(count_query) total_result = await session.execute(count_query)
total = total_result.scalar() or 0 total = total_result.scalar() or 0
# Apply sorting
from sqlalchemy import asc as sa_asc, desc as sa_desc
sort_column_map = {
"created_at": Document.created_at,
"title": Document.title,
"document_type": Document.document_type,
}
sort_col = sort_column_map.get(sort_by, Document.created_at)
query = query.order_by(
sa_desc(sort_col) if sort_order == "desc" else sa_asc(sort_col)
)
# Calculate offset # Calculate offset
offset = 0 offset = 0
if skip is not None: if skip is not None:

View file

@ -10,7 +10,7 @@ from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import desc, func, select, update from sqlalchemy import desc, func, literal, literal_column, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Notification, User, get_async_session from app.db import Notification, User, get_async_session
@ -23,9 +23,26 @@ SYNC_WINDOW_DAYS = 14
# Valid notification types - must match frontend InboxItemTypeEnum # Valid notification types - must match frontend InboxItemTypeEnum
NotificationType = Literal[ NotificationType = Literal[
"connector_indexing", "document_processing", "new_mention", "page_limit_exceeded" "connector_indexing",
"connector_deletion",
"document_processing",
"new_mention",
"comment_reply",
"page_limit_exceeded",
] ]
# Category-to-types mapping for filtering by tab
NotificationCategory = Literal["comments", "status"]
CATEGORY_TYPES: dict[str, tuple[str, ...]] = {
"comments": ("new_mention", "comment_reply"),
"status": (
"connector_indexing",
"connector_deletion",
"document_processing",
"page_limit_exceeded",
),
}
class NotificationResponse(BaseModel): class NotificationResponse(BaseModel):
"""Response model for a single notification.""" """Response model for a single notification."""
@ -69,6 +86,21 @@ class MarkAllReadResponse(BaseModel):
updated_count: int updated_count: int
class SourceTypeItem(BaseModel):
"""A single source type with its category and count."""
key: str
type: str
category: str # "connector" or "document"
count: int
class SourceTypesResponse(BaseModel):
"""Response for notification source types used in status tab filter."""
sources: list[SourceTypeItem]
class UnreadCountResponse(BaseModel): class UnreadCountResponse(BaseModel):
"""Response for unread count with split between recent and older items.""" """Response for unread count with split between recent and older items."""
@ -76,12 +108,86 @@ class UnreadCountResponse(BaseModel):
recent_unread: int # Within SYNC_WINDOW_DAYS recent_unread: int # Within SYNC_WINDOW_DAYS
@router.get("/source-types", response_model=SourceTypesResponse)
async def get_notification_source_types(
search_space_id: int | None = Query(None, description="Filter by search space ID"),
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> SourceTypesResponse:
"""
Get all distinct connector types and document types from the user's
status notifications. Used to populate the filter dropdown in the
inbox Status tab so that all types are shown regardless of pagination.
"""
base_filter = [Notification.user_id == user.id]
if search_space_id is not None:
base_filter.append(
(Notification.search_space_id == search_space_id)
| (Notification.search_space_id.is_(None))
)
connector_type_expr = Notification.notification_metadata["connector_type"].astext
connector_query = (
select(
connector_type_expr.label("source_type"),
literal("connector").label("category"),
func.count(Notification.id).label("cnt"),
)
.where(
*base_filter,
Notification.type.in_(("connector_indexing", "connector_deletion")),
connector_type_expr.isnot(None),
)
.group_by(literal_column("source_type"))
)
document_type_expr = Notification.notification_metadata["document_type"].astext
document_query = (
select(
document_type_expr.label("source_type"),
literal("document").label("category"),
func.count(Notification.id).label("cnt"),
)
.where(
*base_filter,
Notification.type.in_(("document_processing",)),
document_type_expr.isnot(None),
)
.group_by(literal_column("source_type"))
)
connector_result = await session.execute(connector_query)
document_result = await session.execute(document_query)
sources = []
for source_type, category, count in [
*connector_result.all(),
*document_result.all(),
]:
if not source_type:
continue
sources.append(
SourceTypeItem(
key=f"{category}:{source_type}",
type=source_type,
category=category,
count=count,
)
)
return SourceTypesResponse(sources=sources)
@router.get("/unread-count", response_model=UnreadCountResponse) @router.get("/unread-count", response_model=UnreadCountResponse)
async def get_unread_count( async def get_unread_count(
search_space_id: int | None = Query(None, description="Filter by search space ID"), search_space_id: int | None = Query(None, description="Filter by search space ID"),
type_filter: NotificationType | None = Query( type_filter: NotificationType | None = Query(
None, alias="type", description="Filter by notification type" None, alias="type", description="Filter by notification type"
), ),
category: NotificationCategory | None = Query(
None, description="Filter by category: 'comments' or 'status'"
),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
) -> UnreadCountResponse: ) -> UnreadCountResponse:
@ -116,6 +222,10 @@ async def get_unread_count(
if type_filter: if type_filter:
base_filter.append(Notification.type == type_filter) base_filter.append(Notification.type == type_filter)
# Filter by category (maps to multiple types)
if category:
base_filter.append(Notification.type.in_(CATEGORY_TYPES[category]))
# Total unread count (all time) # Total unread count (all time)
total_query = select(func.count(Notification.id)).where(*base_filter) total_query = select(func.count(Notification.id)).where(*base_filter)
total_result = await session.execute(total_query) total_result = await session.execute(total_query)
@ -141,6 +251,17 @@ async def list_notifications(
type_filter: NotificationType | None = Query( type_filter: NotificationType | None = Query(
None, alias="type", description="Filter by notification type" None, alias="type", description="Filter by notification type"
), ),
category: NotificationCategory | None = Query(
None, description="Filter by category: 'comments' or 'status'"
),
source_type: str | None = Query(
None,
description="Filter by source type, e.g. 'connector:GITHUB_CONNECTOR' or 'doctype:FILE'",
),
filter: str | None = Query(
None,
description="Filter preset: 'unread' for unread only, 'errors' for failed/error items only",
),
before_date: str | None = Query( before_date: str | None = Query(
None, description="Get notifications before this ISO date (for pagination)" None, description="Get notifications before this ISO date (for pagination)"
), ),
@ -182,6 +303,45 @@ async def list_notifications(
query = query.where(Notification.type == type_filter) query = query.where(Notification.type == type_filter)
count_query = count_query.where(Notification.type == type_filter) count_query = count_query.where(Notification.type == type_filter)
# Filter by category (maps to multiple types)
if category:
cat_types = CATEGORY_TYPES[category]
query = query.where(Notification.type.in_(cat_types))
count_query = count_query.where(Notification.type.in_(cat_types))
# Filter by source type (connector or document type from JSONB metadata)
if source_type:
if source_type.startswith("connector:"):
connector_val = source_type[len("connector:") :]
source_filter = Notification.type.in_(
("connector_indexing", "connector_deletion")
) & (
Notification.notification_metadata["connector_type"].astext
== connector_val
)
query = query.where(source_filter)
count_query = count_query.where(source_filter)
elif source_type.startswith("doctype:"):
doctype_val = source_type[len("doctype:") :]
source_filter = Notification.type.in_(("document_processing",)) & (
Notification.notification_metadata["document_type"].astext
== doctype_val
)
query = query.where(source_filter)
count_query = count_query.where(source_filter)
# Filter by preset: 'unread' or 'errors'
if filter == "unread":
unread_filter = Notification.read == False # noqa: E712
query = query.where(unread_filter)
count_query = count_query.where(unread_filter)
elif filter == "errors":
error_filter = (Notification.type == "page_limit_exceeded") | (
Notification.notification_metadata["status"].astext == "failed"
)
query = query.where(error_filter)
count_query = count_query.where(error_filter)
# Filter by date (for efficient pagination of older items) # Filter by date (for efficient pagination of older items)
if before_date: if before_date:
try: try:

View file

@ -13,9 +13,7 @@ import {
llmPreferencesAtom, llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms"; } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup"; import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LayoutDataProvider } from "@/components/layout"; import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour"; import { OnboardingTour } from "@/components/onboarding-tour";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@ -27,8 +25,6 @@ export function DashboardClientLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
searchSpaceId: string; searchSpaceId: string;
navSecondary?: any[];
navMain?: any[];
}) { }) {
const t = useTranslations("dashboard"); const t = useTranslations("dashboard");
const router = useRouter(); const router = useRouter();
@ -190,11 +186,7 @@ export function DashboardClientLayout({
return ( return (
<DocumentUploadDialogProvider> <DocumentUploadDialogProvider>
<OnboardingTour /> <OnboardingTour />
<LayoutDataProvider searchSpaceId={searchSpaceId} breadcrumb={<DashboardBreadcrumb />}> <LayoutDataProvider searchSpaceId={searchSpaceId}>{children}</LayoutDataProvider>
{children}
</LayoutDataProvider>
{/* Global connector dialog - triggered from documents page */}
<ConnectorIndicator hideTrigger />
</DocumentUploadDialogProvider> </DocumentUploadDialogProvider>
); );
} }

View file

@ -1,32 +1,9 @@
"use client"; "use client";
import { useSetAtom } from "jotai"; import { ListFilter, Search, Upload, X } from "lucide-react";
import {
CircleAlert,
FileType,
ListFilter,
Search,
SlidersHorizontal,
Trash,
Upload,
X,
} from "lucide-react";
import { motion } from "motion/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import React, { useMemo, useRef, useState } from "react"; import React, { useCallback, useMemo, useRef, useState } from "react";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -36,18 +13,14 @@ import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon";
export function DocumentsFilters({ export function DocumentsFilters({
typeCounts: typeCountsRecord, typeCounts: typeCountsRecord,
selectedIds,
onSearch, onSearch,
searchValue, searchValue,
onBulkDelete,
onToggleType, onToggleType,
activeTypes, activeTypes,
}: { }: {
typeCounts: Partial<Record<DocumentTypeEnum, number>>; typeCounts: Partial<Record<DocumentTypeEnum, number>>;
selectedIds: Set<number>;
onSearch: (v: string) => void; onSearch: (v: string) => void;
searchValue: string; searchValue: string;
onBulkDelete: () => Promise<void>;
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void; onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
activeTypes: DocumentTypeEnum[]; activeTypes: DocumentTypeEnum[];
}) { }) {
@ -55,11 +28,16 @@ export function DocumentsFilters({
const id = React.useId(); const id = React.useId();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Dialog hooks for action buttons
const { openDialog: openUploadDialog } = useDocumentUploadDialog(); const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const [typeSearchQuery, setTypeSearchQuery] = useState(""); const [typeSearchQuery, setTypeSearchQuery] = useState("");
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const uniqueTypes = useMemo(() => { const uniqueTypes = useMemo(() => {
return Object.keys(typeCountsRecord).sort() as DocumentTypeEnum[]; return Object.keys(typeCountsRecord).sort() as DocumentTypeEnum[];
@ -80,235 +58,145 @@ export function DocumentsFilters({
}, [typeCountsRecord]); }, [typeCountsRecord]);
return ( return (
<motion.div <div className="flex select-none">
className="flex flex-col gap-4 select-none" <div className="flex items-center gap-2 w-full">
initial={{ opacity: 0, y: 10 }} {/* Type Filter */}
animate={{ opacity: 1, y: 0 }} <Popover>
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.1 }} <PopoverTrigger asChild>
> <Button
{/* Main toolbar row */} variant="outline"
<div className="flex flex-wrap items-center gap-3"> size="icon"
{/* Action Buttons - Left Side */} className="h-9 w-9 shrink-0 border-dashed border-sidebar-border text-sidebar-foreground/60 hover:text-sidebar-foreground hover:border-sidebar-border bg-sidebar"
<div className="flex items-center gap-2"> >
<Button <ListFilter size={14} />
onClick={openUploadDialog} {activeTypes.length > 0 && (
variant="outline" <span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[9px] font-medium text-primary-foreground">
size="sm" {activeTypes.length}
className="h-9 gap-2 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100" </span>
> )}
<Upload size={16} /> </Button>
<span>Upload documents</span> </PopoverTrigger>
</Button> <PopoverContent className="w-64 !p-0 overflow-hidden" align="end">
<Button <div>
onClick={() => setConnectorDialogOpen(true)} {/* Search input */}
variant="outline" <div className="p-2 border-b border-neutral-700">
size="sm" <div className="relative">
className="h-9 gap-2 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100" <Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
> <Input
<SlidersHorizontal size={16} /> placeholder="Search types"
<span>Manage connectors</span> value={typeSearchQuery}
</Button> onChange={(e) => setTypeSearchQuery(e.target.value)}
</div> className="h-6 pl-6 text-sm bg-transparent border-0 shadow-none focus-visible:ring-0"
/>
</div>
</div>
{/* Spacer */} <div
<div className="flex-1" /> className="max-h-[300px] overflow-y-auto overflow-x-hidden py-1.5 px-1.5"
onScroll={handleScroll}
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{filteredTypes.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No types found
</div>
) : (
filteredTypes.map((value: DocumentTypeEnum, i) => (
<button
type="button"
key={value}
className="flex w-full items-center gap-2.5 py-2 px-3 rounded-md hover:bg-muted/50 transition-colors cursor-pointer text-left"
onClick={() => onToggleType(value, !activeTypes.includes(value))}
>
{/* Icon */}
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted/50 text-foreground/80">
{getDocumentTypeIcon(value, "h-4 w-4")}
</div>
{/* Text content */}
<div className="flex flex-col min-w-0 flex-1 gap-0.5">
<span className="text-[13px] font-medium text-foreground truncate leading-tight">
{getDocumentTypeLabel(value)}
</span>
<span className="text-[11px] text-muted-foreground leading-tight">
{typeCounts.get(value)} document
{(typeCounts.get(value) ?? 0) !== 1 ? "s" : ""}
</span>
</div>
{/* Checkbox */}
<Checkbox
id={`${id}-${i}`}
checked={activeTypes.includes(value)}
onCheckedChange={(checked: boolean) => onToggleType(value, !!checked)}
className="h-4 w-4 shrink-0 rounded border-muted-foreground/30 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
</button>
))
)}
</div>
{activeTypes.length > 0 && (
<div className="px-3 pt-1.5 pb-1.5 border-t border-neutral-700">
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-[11px] text-muted-foreground hover:text-foreground hover:bg-neutral-700"
onClick={() => {
activeTypes.forEach((t) => {
onToggleType(t, false);
});
}}
>
Clear filters
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
{/* Search Input */} {/* Search Input */}
<motion.div <div className="relative flex-1 min-w-0">
className="relative w-[180px]"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground"> <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
<ListFilter size={14} aria-hidden="true" /> <Search size={14} aria-hidden="true" />
</div> </div>
<Input <Input
id={`${id}-input`} id={`${id}-input`}
ref={inputRef} ref={inputRef}
className="peer h-9 w-full pl-9 pr-9 text-sm bg-background border-border/60 focus-visible:ring-1 focus-visible:ring-ring/30 select-none focus:select-text" className="peer h-9 w-full pl-9 pr-9 text-sm bg-sidebar border-border/60 focus-visible:ring-1 focus-visible:ring-ring/30 select-none focus:select-text"
value={searchValue} value={searchValue}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
placeholder="Filter by title" placeholder="Search docs"
type="text" type="text"
aria-label={t("filter_placeholder")} aria-label={t("filter_placeholder")}
/> />
{Boolean(searchValue) && ( {Boolean(searchValue) && (
<motion.button <button
type="button"
className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors" className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear filter" aria-label="Clear filter"
onClick={() => { onClick={() => {
onSearch(""); onSearch("");
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
> >
<X size={14} strokeWidth={2} aria-hidden="true" /> <X size={14} strokeWidth={2} aria-hidden="true" />
</motion.button> </button>
)}
</motion.div>
{/* Filter Buttons Group */}
<div className="flex items-center gap-2 flex-wrap">
{/* Type Filter */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 gap-2 border-dashed border-border/60 text-muted-foreground hover:text-foreground hover:border-border"
>
<FileType size={14} className="text-muted-foreground" />
<span className="hidden sm:inline">Type</span>
{activeTypes.length > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
{activeTypes.length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 !p-0 overflow-hidden" align="end">
<div>
{/* Search input */}
<div className="p-2 border-b border-border/50">
<div className="relative">
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search types"
value={typeSearchQuery}
onChange={(e) => setTypeSearchQuery(e.target.value)}
className="h-6 pl-6 text-sm bg-transparent border-0 focus-visible:ring-0"
/>
</div>
</div>
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden py-1.5 px-1.5">
{filteredTypes.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No types found
</div>
) : (
filteredTypes.map((value: DocumentTypeEnum, i) => (
<div
key={value}
role="button"
tabIndex={0}
className="flex w-full items-center gap-2.5 py-2 px-3 rounded-md hover:bg-muted/50 transition-colors cursor-pointer text-left"
onClick={() => onToggleType(value, !activeTypes.includes(value))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggleType(value, !activeTypes.includes(value));
}
}}
>
{/* Icon */}
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted/50 text-foreground/80">
{getDocumentTypeIcon(value, "h-4 w-4")}
</div>
{/* Text content */}
<div className="flex flex-col min-w-0 flex-1 gap-0.5">
<span className="text-[13px] font-medium text-foreground truncate leading-tight">
{getDocumentTypeLabel(value)}
</span>
<span className="text-[11px] text-muted-foreground leading-tight">
{typeCounts.get(value)} document
{(typeCounts.get(value) ?? 0) !== 1 ? "s" : ""}
</span>
</div>
{/* Checkbox */}
<Checkbox
id={`${id}-${i}`}
checked={activeTypes.includes(value)}
onCheckedChange={(checked: boolean) => onToggleType(value, !!checked)}
className="h-4 w-4 shrink-0 rounded border-muted-foreground/30 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
</div>
))
)}
</div>
{activeTypes.length > 0 && (
<div className="px-3 pt-1.5 pb-1.5 border-t border-border/50">
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => {
activeTypes.forEach((t) => {
onToggleType(t, false);
});
}}
>
Clear filters
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
{/* Bulk Delete Button */}
{selectedIds.size > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
>
{/* Mobile: icon with count */}
<Button variant="destructive" size="sm" className="h-9 gap-1.5 px-2.5 md:hidden">
<Trash size={14} />
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-destructive-foreground/20 text-[10px] font-medium">
{selectedIds.size}
</span>
</Button>
{/* Desktop: full button */}
<Button variant="destructive" size="sm" className="h-9 gap-2 hidden md:flex">
<Trash size={14} />
Delete
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-destructive-foreground/20 text-[10px] font-medium">
{selectedIds.size}
</span>
</Button>
</motion.div>
</AlertDialogTrigger>
<AlertDialogContent className="max-w-md">
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive"
aria-hidden="true"
>
<CircleAlert size={18} strokeWidth={2} />
</div>
<AlertDialogHeader className="flex-1">
<AlertDialogTitle>
Delete {selectedIds.size} document{selectedIds.size !== 1 ? "s" : ""}?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the selected{" "}
{selectedIds.size === 1 ? "document" : "documents"} from your search space.
</AlertDialogDescription>
</AlertDialogHeader>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onBulkDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)} )}
</div> </div>
{/* Upload Button */}
<Button
onClick={openUploadDialog}
variant="outline"
size="sm"
className="h-9 shrink-0 gap-1.5 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
>
<Upload size={14} />
<span>Upload</span>
</Button>
</div> </div>
</motion.div> </div>
); );
} }

View file

@ -95,7 +95,6 @@ export function RowActions({
{/* Desktop Actions */} {/* Desktop Actions */}
<div className="hidden md:inline-flex items-center justify-center"> <div className="hidden md:inline-flex items-center justify-center">
{isEditable ? ( {isEditable ? (
// Editable documents: show 3-dot dropdown with edit + delete
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@ -123,9 +122,7 @@ export function RowActions({
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)} onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
disabled={isDeleteDisabled} disabled={isDeleteDisabled}
className={ className={
isDeleteDisabled isDeleteDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
? "text-muted-foreground cursor-not-allowed opacity-50"
: "text-destructive focus:text-destructive"
} }
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
@ -135,12 +132,11 @@ export function RowActions({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : ( ) : (
// Non-editable documents: show only delete button directly
shouldShowDelete && ( shouldShowDelete && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-destructive hover:bg-destructive/10"}`} className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)} onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
disabled={isDeleting || isDeleteDisabled} disabled={isDeleting || isDeleteDisabled}
> >
@ -154,7 +150,6 @@ export function RowActions({
{/* Mobile Actions Dropdown */} {/* Mobile Actions Dropdown */}
<div className="inline-flex md:hidden items-center justify-center"> <div className="inline-flex md:hidden items-center justify-center">
{isEditable ? ( {isEditable ? (
// Editable documents: show 3-dot dropdown
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground"> <Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground">
@ -178,9 +173,7 @@ export function RowActions({
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)} onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
disabled={isDeleteDisabled} disabled={isDeleteDisabled}
className={ className={
isDeleteDisabled isDeleteDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
? "text-muted-foreground cursor-not-allowed opacity-50"
: "text-destructive focus:text-destructive"
} }
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
@ -190,12 +183,11 @@ export function RowActions({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : ( ) : (
// Non-editable documents: show only delete button directly
shouldShowDelete && ( shouldShowDelete && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-destructive hover:bg-destructive/10"}`} className={`h-8 w-8 ${isDeleteDisabled ? "text-muted-foreground cursor-not-allowed" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)} onClick={() => !isDeleteDisabled && setIsDeleteOpen(true)}
disabled={isDeleting || isDeleteDisabled} disabled={isDeleting || isDeleteDisabled}
> >

View file

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

View file

@ -256,7 +256,7 @@ export default function EditorPage() {
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
toast.success("Note created successfully! Reindexing in background..."); toast.success("Note created successfully! Reindexing in background...");
router.push(`/dashboard/${searchSpaceId}/documents`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
} else { } else {
// Existing document — save // Existing document — save
const response = await authenticatedFetch( const response = await authenticatedFetch(
@ -277,7 +277,7 @@ export default function EditorPage() {
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
toast.success("Document saved! Reindexing in background..."); toast.success("Document saved! Reindexing in background...");
router.push(`/dashboard/${searchSpaceId}/documents`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
} }
} catch (error) { } catch (error) {
console.error("Error saving document:", error); console.error("Error saving document:", error);
@ -298,7 +298,7 @@ export default function EditorPage() {
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
setShowUnsavedDialog(true); setShowUnsavedDialog(true);
} else { } else {
router.push(`/dashboard/${searchSpaceId}/documents`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
} }
}; };
@ -311,7 +311,7 @@ export default function EditorPage() {
router.push(pendingNavigation); router.push(pendingNavigation);
setPendingNavigation(null); setPendingNavigation(null);
} else { } else {
router.push(`/dashboard/${searchSpaceId}/documents`); router.push(`/dashboard/${searchSpaceId}/new-chat`);
} }
}; };

View file

@ -10,44 +10,7 @@ export default function DashboardLayout({
params: Promise<{ search_space_id: string }>; params: Promise<{ search_space_id: string }>;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
// Use React.use to unwrap the params Promise
const { search_space_id } = use(params); const { search_space_id } = use(params);
const customNavSecondary = [ return <DashboardClientLayout searchSpaceId={search_space_id}>{children}</DashboardClientLayout>;
{
title: `All Search Spaces`,
url: `#`,
icon: "Info",
},
{
title: `All Search Spaces`,
url: "/dashboard",
icon: "Undo2",
},
];
const customNavMain = [
{
title: "Chat",
url: `/dashboard/${search_space_id}/new-chat`,
icon: "MessageCircle",
items: [],
},
{
title: "Documents",
url: `/dashboard/${search_space_id}/documents`,
icon: "SquareLibrary",
items: [],
},
];
return (
<DashboardClientLayout
searchSpaceId={search_space_id}
navSecondary={customNavSecondary}
navMain={customNavMain}
>
{children}
</DashboardClientLayout>
);
} }

View file

@ -1222,7 +1222,6 @@ function LogRowActions({ row, t }: { row: Row<Log>; t: (key: string) => string }
<AlertDialog open={isOpen} onOpenChange={setIsOpen}> <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={(e) => { onSelect={(e) => {
e.preventDefault(); e.preventDefault();
setIsOpen(true); setIsOpen(true);

View file

@ -22,6 +22,7 @@ import {
mentionedDocumentIdsAtom, mentionedDocumentIdsAtom,
mentionedDocumentsAtom, mentionedDocumentsAtom,
messageDocumentsMapAtom, messageDocumentsMapAtom,
sidebarSelectedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom"; } from "@/atoms/chat/mentioned-documents.atom";
import { import {
clearPlanOwnerRegistry, clearPlanOwnerRegistry,
@ -31,7 +32,6 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
import { ChatHeader } from "@/components/new-chat/chat-header";
import { ReportPanel } from "@/components/report-panel/report-panel"; import { ReportPanel } from "@/components/report-panel/report-panel";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
@ -180,11 +180,12 @@ export default function NewChatPage() {
interruptData: Record<string, unknown>; interruptData: Record<string, unknown>;
} | null>(null); } | null>(null);
// Get mentioned document IDs from the composer // Get mentioned document IDs from the composer (derived from @ mentions + sidebar selections)
const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom); const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom);
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom); const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom); const sidebarDocuments = useAtomValue(sidebarSelectedDocumentsAtom);
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom); const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
const setSidebarDocuments = useSetAtom(sidebarSelectedDocumentsAtom);
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom); const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
const setCurrentThreadState = useSetAtom(currentThreadAtom); const setCurrentThreadState = useSetAtom(currentThreadAtom);
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom); const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
@ -276,11 +277,8 @@ export default function NewChatPage() {
setThreadId(null); setThreadId(null);
setCurrentThread(null); setCurrentThread(null);
setMessageThinkingSteps(new Map()); setMessageThinkingSteps(new Map());
setMentionedDocumentIds({
surfsense_doc_ids: [],
document_ids: [],
});
setMentionedDocuments([]); setMentionedDocuments([]);
setSidebarDocuments([]);
setMessageDocumentsMap({}); setMessageDocumentsMap({});
clearPlanOwnerRegistry(); // Reset plan ownership for new chat clearPlanOwnerRegistry(); // Reset plan ownership for new chat
closeReportPanel(); // Close report panel when switching chats closeReportPanel(); // Close report panel when switching chats
@ -345,8 +343,8 @@ export default function NewChatPage() {
}, [ }, [
urlChatId, urlChatId,
setMessageDocumentsMap, setMessageDocumentsMap,
setMentionedDocumentIds,
setMentionedDocuments, setMentionedDocuments,
setSidebarDocuments,
closeReportPanel, closeReportPanel,
]); ]);
@ -473,7 +471,7 @@ export default function NewChatPage() {
const newThread = await createThread(searchSpaceId, initialTitle); const newThread = await createThread(searchSpaceId, initialTitle);
currentThreadId = newThread.id; currentThreadId = newThread.id;
setThreadId(currentThreadId); setThreadId(currentThreadId);
// Set currentThread so ChatHeader can show share button immediately // Set currentThread so share button in header appears immediately
setCurrentThread(newThread); setCurrentThread(newThread);
// Track chat creation // Track chat creation
@ -528,31 +526,30 @@ export default function NewChatPage() {
messageLength: userQuery.length, messageLength: userQuery.length,
}); });
// Store mentioned documents with this message for display // Combine @-mention chips + sidebar selections for display & persistence
if (mentionedDocuments.length > 0) { const allMentionedDocs: MentionedDocumentInfo[] = [];
const docsInfo: MentionedDocumentInfo[] = mentionedDocuments.map((doc) => ({ const seenDocKeys = new Set<string>();
id: doc.id, for (const doc of [...mentionedDocuments, ...sidebarDocuments]) {
title: doc.title, const key = `${doc.document_type}:${doc.id}`;
document_type: doc.document_type, if (!seenDocKeys.has(key)) {
})); seenDocKeys.add(key);
allMentionedDocs.push({ id: doc.id, title: doc.title, document_type: doc.document_type });
}
}
if (allMentionedDocs.length > 0) {
setMessageDocumentsMap((prev) => ({ setMessageDocumentsMap((prev) => ({
...prev, ...prev,
[userMsgId]: docsInfo, [userMsgId]: allMentionedDocs,
})); }));
} }
// Persist user message with mentioned documents (don't await, fire and forget)
const persistContent: unknown[] = [...message.content]; const persistContent: unknown[] = [...message.content];
// Add mentioned documents for persistence if (allMentionedDocs.length > 0) {
if (mentionedDocuments.length > 0) {
persistContent.push({ persistContent.push({
type: "mentioned-documents", type: "mentioned-documents",
documents: mentionedDocuments.map((doc) => ({ documents: allMentionedDocs,
id: doc.id,
title: doc.title,
document_type: doc.document_type,
})),
}); });
} }
@ -560,7 +557,17 @@ export default function NewChatPage() {
role: "user", role: "user",
content: persistContent, content: persistContent,
}) })
.then(() => { .then((savedMessage) => {
const newUserMsgId = `msg-${savedMessage.id}`;
setMessages((prev) =>
prev.map((m) => (m.id === userMsgId ? { ...m, id: newUserMsgId } : m))
);
setMessageDocumentsMap((prev) => {
const docs = prev[userMsgId];
if (!docs) return prev;
const { [userMsgId]: _, ...rest } = prev;
return { ...rest, [newUserMsgId]: docs };
});
if (isNewThread) { if (isNewThread) {
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] }); queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
} }
@ -618,11 +625,8 @@ export default function NewChatPage() {
// Clear mentioned documents after capturing them // Clear mentioned documents after capturing them
if (hasDocumentIds || hasSurfsenseDocIds) { if (hasDocumentIds || hasSurfsenseDocIds) {
setMentionedDocumentIds({
surfsense_doc_ids: [],
document_ids: [],
});
setMentionedDocuments([]); setMentionedDocuments([]);
setSidebarDocuments([]);
} }
const response = await fetch(`${backendUrl}/api/v1/new_chat`, { const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
@ -747,15 +751,6 @@ export default function NewChatPage() {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["threads", String(searchSpaceId)], queryKey: ["threads", String(searchSpaceId)],
}); });
// Invalidate thread detail for breadcrumb update
queryClient.invalidateQueries({
queryKey: [
"threads",
String(searchSpaceId),
"detail",
String(titleData.threadId),
],
});
} }
break; break;
} }
@ -920,8 +915,9 @@ export default function NewChatPage() {
messages, messages,
mentionedDocumentIds, mentionedDocumentIds,
mentionedDocuments, mentionedDocuments,
setMentionedDocumentIds, sidebarDocuments,
setMentionedDocuments, setMentionedDocuments,
setSidebarDocuments,
setMessageDocumentsMap, setMessageDocumentsMap,
queryClient, queryClient,
currentThread, currentThread,
@ -1674,10 +1670,7 @@ export default function NewChatPage() {
{/* <WriteTodosToolUI /> Disabled for now */} {/* <WriteTodosToolUI /> Disabled for now */}
<div className="flex h-[calc(100dvh-64px)] overflow-hidden"> <div className="flex h-[calc(100dvh-64px)] overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden"> <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Thread <Thread messageThinkingSteps={messageThinkingSteps} />
messageThinkingSteps={messageThinkingSteps}
header={<ChatHeader searchSpaceId={searchSpaceId} />}
/>
</div> </div>
<ReportPanel /> <ReportPanel />
</div> </div>

View file

@ -564,7 +564,7 @@ function MemberRow({
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
className="min-w-[120px] bg-muted dark:border dark:border-neutral-700" className="min-w-[120px]"
> >
{canManageRoles && {canManageRoles &&
roles roles
@ -581,8 +581,8 @@ function MemberRow({
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
className="text-destructive focus:text-destructive"
> >
Remove Remove
</DropdownMenuItem> </DropdownMenuItem>
@ -607,7 +607,7 @@ function MemberRow({
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
)} )}
<DropdownMenuSeparator className="dark:bg-neutral-700" /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`) router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`)
@ -811,7 +811,7 @@ function CreateInviteDialog({
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-start text-left font-normal", "w-full justify-start text-left font-normal bg-transparent",
!expiresAt && "text-muted-foreground" !expiresAt && "text-muted-foreground"
)} )}
> >
@ -833,7 +833,7 @@ function CreateInviteDialog({
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={handleClose}> <Button variant="secondary" onClick={handleClose}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleCreate} disabled={creating}> <Button onClick={handleCreate} disabled={creating}>

View file

@ -231,7 +231,7 @@ button {
} }
} }
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}'; @source "../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}";
@source '../node_modules/streamdown/dist/*.js'; @source "../node_modules/streamdown/dist/*.js";
@source '../node_modules/@streamdown/code/dist/*.js'; @source "../node_modules/@streamdown/code/dist/*.js";
@source '../node_modules/@streamdown/math/dist/*.js'; @source "../node_modules/@streamdown/math/dist/*.js";

View file

@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
const backendBaseUrl = (process.env.INTERNAL_FASTAPI_BACKEND_URL || "http://backend:8000").replace( const backendBaseUrl = (process.env.INTERNAL_FASTAPI_BACKEND_URL || "http://backend:8000").replace(
/\/+$/, /\/+$/,

View file

@ -1,26 +1,47 @@
"use client"; "use client";
import { atom } from "jotai"; import { atom } from "jotai";
import type { Document, SurfsenseDocsDocument } from "@/contracts/types/document.types"; import type { Document } from "@/contracts/types/document.types";
/** /**
* Atom to store the IDs of documents mentioned in the current chat composer. * Atom to store the full document objects mentioned via @-mention chips
* This is used to pass document context to the backend when sending a message. * in the current chat composer. This persists across component remounts.
*/
export const mentionedDocumentIdsAtom = atom<{
surfsense_doc_ids: number[];
document_ids: number[];
}>({
surfsense_doc_ids: [],
document_ids: [],
});
/**
* Atom to store the full document objects mentioned in the current chat composer.
* This persists across component remounts.
*/ */
export const mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]); export const mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]);
/**
* Atom to store documents selected via the sidebar checkboxes / row clicks.
* These are NOT inserted as chips the composer shows a count badge instead.
*/
export const sidebarSelectedDocumentsAtom = atom<
Pick<Document, "id" | "title" | "document_type">[]
>([]);
/**
* Derived read-only atom that merges @-mention chips and sidebar selections
* into a single deduplicated set of document IDs for the backend.
*/
export const mentionedDocumentIdsAtom = atom((get) => {
const chipDocs = get(mentionedDocumentsAtom);
const sidebarDocs = get(sidebarSelectedDocumentsAtom);
const allDocs = [...chipDocs, ...sidebarDocs];
const seen = new Set<string>();
const deduped = allDocs.filter((d) => {
const key = `${d.document_type}:${d.id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
return {
surfsense_doc_ids: deduped
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: deduped
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
};
});
/** /**
* Simplified document info for display purposes * Simplified document info for display purposes
*/ */

View file

@ -49,7 +49,6 @@ export const uploadDocumentMutationAtom = atomWithMutation((get) => {
onSuccess: () => { onSuccess: () => {
// Note: Toast notification is handled by the caller (DocumentUploadTab) to use i18n // Note: Toast notification is handled by the caller (DocumentUploadTab) to use i18n
// Invalidate logs summary to show new processing tasks immediately on documents page
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined), queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined),
}); });

View file

@ -5,3 +5,5 @@ export const globalDocumentsQueryParamsAtom = atom<GetDocumentsRequest["queryPar
page_size: 10, page_size: 10,
page: 0, page: 0,
}); });
export const documentsSidebarOpenAtom = atom(false);

View file

@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.1.2/schema.json", "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
@ -7,7 +7,7 @@
}, },
"files": { "files": {
"ignoreUnknown": true, "ignoreUnknown": true,
"experimentalScannerIgnores": ["node_modules", ".git", ".next", "dist", "build", "coverage"], "includes": ["**", "!!node_modules", "!!.git", "!!.next", "!!dist", "!!build", "!!coverage"],
"maxSize": 1048576 "maxSize": 1048576
}, },
"formatter": { "formatter": {
@ -65,6 +65,9 @@
} }
}, },
"css": { "css": {
"parser": {
"tailwindDirectives": true
},
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "tab", "indentStyle": "tab",

View file

@ -1,14 +1,6 @@
"use client"; "use client";
import { import { Bell, ExternalLink, Info, type LucideIcon, Rocket, Wrench, Zap } from "lucide-react";
Bell,
ExternalLink,
Info,
type LucideIcon,
Rocket,
Wrench,
Zap,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -114,4 +106,3 @@ export function AnnouncementCard({ announcement }: { announcement: AnnouncementW
</Card> </Card>
); );
} }

View file

@ -15,4 +15,3 @@ export function AnnouncementsEmptyState() {
</div> </div>
); );
} }

View file

@ -4,7 +4,7 @@ import { useAtomValue } from "jotai";
import { AlertTriangle, Cable, Settings } from "lucide-react"; import { AlertTriangle, Cable, Settings } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import type { FC } from "react"; import { type FC, useMemo } from "react";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { import {
globalNewLLMConfigsAtom, globalNewLLMConfigsAtom,
@ -37,7 +37,7 @@ import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view"; import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view"; import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger = false }) => { export const ConnectorIndicator: FC = () => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { data: currentUser } = useAtomValue(currentUserAtom); const { data: currentUser } = useAtomValue(currentUserAtom);
@ -66,11 +66,15 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
const { data: documentTypeCounts, isFetching: documentTypesLoading } = const { data: documentTypeCounts, isFetching: documentTypesLoading } =
useAtomValue(documentTypeCountsAtom); useAtomValue(documentTypeCountsAtom);
// Fetch notifications to detect indexing failures // Fetch status notifications to detect indexing failures
const { inboxItems = [] } = useInbox( const { inboxItems: statusInboxItems = [] } = useInbox(
currentUser?.id ?? null, currentUser?.id ?? null,
searchSpaceId ? Number(searchSpaceId) : null, searchSpaceId ? Number(searchSpaceId) : null,
"connector_indexing" "status"
);
const inboxItems = useMemo(
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
[statusInboxItems]
); );
// Check if YouTube view is active // Check if YouTube view is active
@ -189,40 +193,36 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
return ( return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}> <Dialog open={isOpen} onOpenChange={handleOpenChange}>
{!hideTrigger && ( <TooltipIconButton
<TooltipIconButton data-joyride="connector-icon"
data-joyride="connector-icon" tooltip={hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"}
tooltip={ side="bottom"
hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data" className={cn(
} "size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
side="bottom" "hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
className={cn( "outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative", "border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30", )}
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs", aria-label={
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none" hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
)} }
aria-label={ onClick={() => handleOpenChange(true)}
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector" >
} {isLoading ? (
onClick={() => handleOpenChange(true)} <Spinner size="sm" />
> ) : (
{isLoading ? ( <>
<Spinner size="sm" /> <Cable className="size-4 stroke-[1.5px]" />
) : ( {activeConnectorsCount > 0 && (
<> <span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm select-none">
<Cable className="size-4 stroke-[1.5px]" /> {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
{activeConnectorsCount > 0 && ( </span>
<span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm"> )}
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount} </>
</span> )}
)} </TooltipIconButton>
</>
)}
</TooltipIconButton>
)}
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none"> <DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none">
<DialogTitle className="sr-only">Manage Connectors</DialogTitle> <DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */} {/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? ( {isYouTubeView && searchSpaceId ? (
@ -415,7 +415,6 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
activeDocumentTypes={activeDocumentTypes} activeDocumentTypes={activeDocumentTypes}
connectors={connectors as SearchSourceConnector[]} connectors={connectors as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds} indexingConnectorIds={indexingConnectorIds}
searchSpaceId={searchSpaceId}
onTabChange={handleTabChange} onTabChange={handleTabChange}
onManage={handleStartEdit} onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList} onViewAccountsList={handleViewAccountsList}

View file

@ -1,18 +1,13 @@
"use client"; "use client";
import { ArrowRight, Cable } from "lucide-react"; import { Cable } from "lucide-react";
import { useRouter } from "next/navigation";
import type { FC } from "react"; import type { FC } from "react";
import { useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants"; import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping"; import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
@ -25,37 +20,21 @@ interface ActiveConnectorsTabProps {
activeDocumentTypes: Array<[string, number]>; activeDocumentTypes: Array<[string, number]>;
connectors: SearchSourceConnector[]; connectors: SearchSourceConnector[];
indexingConnectorIds: Set<number>; indexingConnectorIds: Set<number>;
searchSpaceId: string;
onTabChange: (value: string) => void; onTabChange: (value: string) => void;
onManage?: (connector: SearchSourceConnector) => void; onManage?: (connector: SearchSourceConnector) => void;
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void; onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
} }
/**
* Check if a connector type is indexable
*/
function isIndexableConnector(connectorType: string): boolean {
const nonIndexableTypes = ["MCP_CONNECTOR"];
return !nonIndexableTypes.includes(connectorType);
}
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
searchQuery, searchQuery,
hasSources, hasSources,
activeDocumentTypes, activeDocumentTypes,
connectors, connectors,
indexingConnectorIds, indexingConnectorIds,
searchSpaceId, onTabChange: _onTabChange,
onTabChange,
onManage, onManage,
onViewAccountsList, onViewAccountsList,
}) => { }) => {
const router = useRouter();
const handleViewAllDocuments = () => {
router.push(`/dashboard/${searchSpaceId}/documents`);
};
// Convert activeDocumentTypes array to Record for utility function // Convert activeDocumentTypes array to Record for utility function
const documentTypeCounts = activeDocumentTypes.reduce( const documentTypeCounts = activeDocumentTypes.reduce(
(acc, [docType, count]) => { (acc, [docType, count]) => {
@ -300,15 +279,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">Documents</h3> <h3 className="text-sm font-semibold text-muted-foreground">Documents</h3>
<Button
variant="ghost"
size="sm"
onClick={handleViewAllDocuments}
className="h-7 text-xs text-muted-foreground hover:text-foreground gap-1.5"
>
View all documents
<ArrowRight className="size-3" />
</Button>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{standaloneDocuments.map((doc) => ( {standaloneDocuments.map((doc) => (

View file

@ -3,7 +3,6 @@
import { TagInput, type Tag as TagType } from "emblor"; import { TagInput, type Tag as TagType } from "emblor";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { type FC, useState } from "react"; import { type FC, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -24,7 +23,6 @@ interface YouTubeCrawlerViewProps {
export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId, onBack }) => { export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId, onBack }) => {
const t = useTranslations("add_youtube"); const t = useTranslations("add_youtube");
const router = useRouter();
const [videoTags, setVideoTags] = useState<TagType[]>([]); const [videoTags, setVideoTags] = useState<TagType[]>([]);
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null); const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -74,9 +72,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
toast(t("success_toast"), { toast(t("success_toast"), {
description: t("success_toast_desc"), description: t("success_toast_desc"),
}); });
// Close the popup and navigate to documents
onBack(); onBack();
router.push(`/dashboard/${searchSpaceId}/documents`);
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
const errorMessage = error instanceof Error ? error.message : t("error_generic"); const errorMessage = error instanceof Error ? error.message : t("error_generic");

View file

@ -120,7 +120,12 @@ const DocumentUploadPopupContent: FC<{
return ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"> <DialogContent
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"
>
<DialogTitle className="sr-only">Upload Document</DialogTitle> <DialogTitle className="sr-only">Upload Document</DialogTitle>
{/* Scrollable container for mobile */} {/* Scrollable container for mobile */}

View file

@ -27,6 +27,7 @@ export interface InlineMentionEditorRef {
getText: () => string; getText: () => string;
getMentionedDocuments: () => MentionedDocument[]; getMentionedDocuments: () => MentionedDocument[];
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void; insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
removeDocumentChip: (docId: number, docType?: string) => void;
setDocumentChipStatus: ( setDocumentChipStatus: (
docId: number, docId: number,
docType: string | undefined, docType: string | undefined,
@ -175,33 +176,27 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN"); chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
chip.contentEditable = "false"; chip.contentEditable = "false";
chip.className = chip.className =
"inline-flex items-center gap-1 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none"; "inline-flex items-center gap-1 mx-0.5 px-1 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none cursor-default";
chip.style.userSelect = "none"; chip.style.userSelect = "none";
chip.style.verticalAlign = "baseline"; chip.style.verticalAlign = "baseline";
// Add document type icon // Container that swaps between icon and remove button on hover
const iconContainer = document.createElement("span");
iconContainer.className = "shrink-0 flex items-center size-3 relative";
const iconSpan = document.createElement("span"); const iconSpan = document.createElement("span");
iconSpan.className = "shrink-0 flex items-center text-muted-foreground"; iconSpan.className = "flex items-center text-muted-foreground";
iconSpan.innerHTML = ReactDOMServer.renderToString( iconSpan.innerHTML = ReactDOMServer.renderToString(
getConnectorIcon(doc.document_type ?? "UNKNOWN", "h-3 w-3") getConnectorIcon(doc.document_type ?? "UNKNOWN", "h-3 w-3")
); );
const titleSpan = document.createElement("span");
titleSpan.className = "max-w-[120px] truncate";
titleSpan.textContent = doc.title;
titleSpan.title = doc.title;
titleSpan.setAttribute("data-mention-title", "true");
const statusSpan = document.createElement("span");
statusSpan.setAttribute(CHIP_STATUS_ATTR, "true");
statusSpan.className = "text-[10px] font-semibold opacity-80 hidden";
const removeBtn = document.createElement("button"); const removeBtn = document.createElement("button");
removeBtn.type = "button"; removeBtn.type = "button";
removeBtn.className = removeBtn.className =
"size-3 flex items-center justify-center rounded-full hover:bg-primary/20 transition-colors ml-0.5"; "size-3 items-center justify-center rounded-full text-muted-foreground transition-colors";
removeBtn.style.display = "none";
removeBtn.innerHTML = ReactDOMServer.renderToString( removeBtn.innerHTML = ReactDOMServer.renderToString(
createElement(X, { className: "h-2.5 w-2.5", strokeWidth: 2.5 }) createElement(X, { className: "h-3 w-3", strokeWidth: 2.5 })
); );
removeBtn.onclick = (e) => { removeBtn.onclick = (e) => {
e.preventDefault(); e.preventDefault();
@ -213,15 +208,45 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
next.delete(docKey); next.delete(docKey);
return next; return next;
}); });
// Notify parent that a document was removed
onDocumentRemove?.(doc.id, doc.document_type); onDocumentRemove?.(doc.id, doc.document_type);
focusAtEnd(); focusAtEnd();
}; };
chip.appendChild(iconSpan); const titleSpan = document.createElement("span");
chip.appendChild(titleSpan); titleSpan.className = "max-w-[120px] truncate";
chip.appendChild(statusSpan); titleSpan.textContent = doc.title;
chip.appendChild(removeBtn); titleSpan.title = doc.title;
titleSpan.setAttribute("data-mention-title", "true");
const statusSpan = document.createElement("span");
statusSpan.setAttribute(CHIP_STATUS_ATTR, "true");
statusSpan.className = "text-[10px] font-semibold opacity-80 hidden";
const isTouchDevice = window.matchMedia("(hover: none)").matches;
if (isTouchDevice) {
// Mobile: icon on left, title, X on right
chip.appendChild(iconSpan);
chip.appendChild(titleSpan);
chip.appendChild(statusSpan);
removeBtn.style.display = "flex";
removeBtn.className += " ml-0.5";
chip.appendChild(removeBtn);
} else {
// Desktop: icon/X swap on hover in the same slot
iconContainer.appendChild(iconSpan);
iconContainer.appendChild(removeBtn);
chip.addEventListener("mouseenter", () => {
iconSpan.style.display = "none";
removeBtn.style.display = "flex";
});
chip.addEventListener("mouseleave", () => {
iconSpan.style.display = "";
removeBtn.style.display = "none";
});
chip.appendChild(iconContainer);
chip.appendChild(titleSpan);
chip.appendChild(statusSpan);
}
return chip; return chip;
}, },
@ -388,6 +413,32 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[] []
); );
const removeDocumentChip = useCallback(
(docId: number, docType?: string) => {
if (!editorRef.current) return;
const chipKey = `${docType ?? "UNKNOWN"}:${docId}`;
const chips = editorRef.current.querySelectorAll<HTMLSpanElement>(
`span[${CHIP_DATA_ATTR}="true"]`
);
for (const chip of chips) {
if (getChipId(chip) === docId && getChipDocType(chip) === (docType ?? "UNKNOWN")) {
chip.remove();
break;
}
}
setMentionedDocs((prev) => {
const next = new Map(prev);
next.delete(chipKey);
return next;
});
const text = getText();
const empty = text.length === 0 && mentionedDocs.size <= 1;
setIsEmpty(empty);
},
[getText, mentionedDocs.size]
);
// Expose methods via ref // Expose methods via ref
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focus: () => editorRef.current?.focus(), focus: () => editorRef.current?.focus(),
@ -395,6 +446,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
getText, getText,
getMentionedDocuments, getMentionedDocuments,
insertDocumentChip, insertDocumentChip,
removeDocumentChip,
setDocumentChipStatus, setDocumentChipStatus,
})); }));

View file

@ -268,7 +268,7 @@ function ThreadListItemComponent({
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive"> <DropdownMenuItem onClick={onDelete}>
<TrashIcon className="mr-2 size-4" /> <TrashIcon className="mr-2 size-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -18,23 +18,21 @@ import {
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
CopyIcon, CopyIcon,
Dot,
DownloadIcon, DownloadIcon,
FileWarning, PlusIcon,
Paperclip,
RefreshCwIcon, RefreshCwIcon,
SquareIcon, SquareIcon,
} from "lucide-react"; } from "lucide-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { toast } from "sonner";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom"; import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
import { import {
mentionedDocumentIdsAtom,
mentionedDocumentsAtom, mentionedDocumentsAtom,
sidebarSelectedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom"; } from "@/atoms/chat/mentioned-documents.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { membersAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms";
import { import {
globalNewLLMConfigsAtom, globalNewLLMConfigsAtom,
@ -45,6 +43,7 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message"; import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status"; import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup"; import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { import {
InlineMentionEditor, InlineMentionEditor,
type InlineMentionEditorRef, type InlineMentionEditorRef,
@ -63,11 +62,9 @@ import {
} from "@/components/new-chat/document-mention-picker"; } from "@/components/new-chat/document-mention-picker";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { Document } from "@/contracts/types/document.types"; import type { Document } from "@/contracts/types/document.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments"; import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsElectric } from "@/hooks/use-comments-electric"; import { useCommentsElectric } from "@/hooks/use-comments-electric";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** Placeholder texts that cycle in new chats when input is empty */ /** Placeholder texts that cycle in new chats when input is empty */
@ -80,37 +77,19 @@ const CYCLING_PLACEHOLDERS = [
"Check if this week's Slack messages reference any GitHub issues.", "Check if this week's Slack messages reference any GitHub issues.",
]; ];
const CHAT_UPLOAD_ACCEPT =
".pdf,.doc,.docx,.txt,.md,.markdown,.ppt,.pptx,.xls,.xlsx,.xlsm,.xlsb,.csv,.html,.htm,.xml,.rtf,.epub,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.tif,.mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm";
const CHAT_MAX_FILES = 10;
const CHAT_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB per file
const CHAT_MAX_TOTAL_SIZE_BYTES = 200 * 1024 * 1024; // 200 MB total
type UploadState = "pending" | "processing" | "ready" | "failed";
interface UploadedMentionDoc {
id: number;
title: string;
document_type: Document["document_type"];
state: UploadState;
reason?: string | null;
}
interface ThreadProps { interface ThreadProps {
messageThinkingSteps?: Map<string, ThinkingStep[]>; messageThinkingSteps?: Map<string, ThinkingStep[]>;
header?: React.ReactNode;
} }
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map(), header }) => { export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) => {
return ( return (
<ThinkingStepsContext.Provider value={messageThinkingSteps}> <ThinkingStepsContext.Provider value={messageThinkingSteps}>
<ThreadContent header={header} /> <ThreadContent />
</ThinkingStepsContext.Provider> </ThinkingStepsContext.Provider>
); );
}; };
const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => { const ThreadContent: FC = () => {
const showGutter = useAtomValue(showCommentsGutterAtom); const showGutter = useAtomValue(showCommentsGutterAtom);
return ( return (
@ -128,8 +107,6 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
showGutter && "lg:pr-30" showGutter && "lg:pr-30"
)} )}
> >
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
<AssistantIf condition={({ thread }) => thread.isEmpty}> <AssistantIf condition={({ thread }) => thread.isEmpty}>
<ThreadWelcome /> <ThreadWelcome />
</AssistantIf> </AssistantIf>
@ -250,19 +227,13 @@ const ThreadWelcome: FC = () => {
const Composer: FC = () => { const Composer: FC = () => {
// Document mention state (atoms persist across component remounts) // Document mention state (atoms persist across component remounts)
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false); const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState(""); const [mentionQuery, setMentionQuery] = useState("");
const [uploadedMentionDocs, setUploadedMentionDocs] = useState<
Record<number, UploadedMentionDoc>
>({});
const [isUploadingDocs, setIsUploadingDocs] = useState(false);
const editorRef = useRef<InlineMentionEditorRef>(null); const editorRef = useRef<InlineMentionEditorRef>(null);
const editorContainerRef = useRef<HTMLDivElement>(null); const editorContainerRef = useRef<HTMLDivElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const isFileDialogOpenRef = useRef(false);
const documentPickerRef = useRef<DocumentMentionPickerRef>(null); const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const { search_space_id, chat_id } = useParams(); const { search_space_id, chat_id } = useParams();
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
const composerRuntime = useComposerRuntime(); const composerRuntime = useComposerRuntime();
const hasAutoFocusedRef = useRef(false); const hasAutoFocusedRef = useRef(false);
@ -317,7 +288,7 @@ const Composer: FC = () => {
const assistantIdsKey = useAssistantState(({ thread }) => const assistantIdsKey = useAssistantState(({ thread }) =>
thread.messages thread.messages
.filter((m) => m.role === "assistant" && m.id?.startsWith("msg-")) .filter((m) => m.role === "assistant" && m.id?.startsWith("msg-"))
.map((m) => m.id!.replace("msg-", "")) .map((m) => m.id?.replace("msg-", ""))
.join(",") .join(",")
); );
const assistantDbMessageIds = useMemo( const assistantDbMessageIds = useMemo(
@ -337,18 +308,6 @@ const Composer: FC = () => {
} }
}, [isThreadEmpty]); }, [isThreadEmpty]);
// Sync mentioned document IDs to atom for inclusion in chat request payload
useEffect(() => {
setMentionedDocumentIds({
surfsense_doc_ids: mentionedDocuments
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: mentionedDocuments
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
}, [mentionedDocuments, setMentionedDocumentIds]);
// Sync editor text with assistant-ui composer runtime // Sync editor text with assistant-ui composer runtime
const handleEditorChange = useCallback( const handleEditorChange = useCallback(
(text: string) => { (text: string) => {
@ -401,75 +360,35 @@ const Composer: FC = () => {
[showDocumentPopover] [showDocumentPopover]
); );
const uploadedMentionedDocs = useMemo(
() => mentionedDocuments.filter((doc) => uploadedMentionDocs[doc.id]),
[mentionedDocuments, uploadedMentionDocs]
);
const blockingUploadedMentions = useMemo(
() =>
uploadedMentionedDocs.filter((doc) => {
const state = uploadedMentionDocs[doc.id]?.state;
return state === "pending" || state === "processing" || state === "failed";
}),
[uploadedMentionedDocs, uploadedMentionDocs]
);
// Submit message (blocked during streaming, document picker open, or AI responding to another user) // Submit message (blocked during streaming, document picker open, or AI responding to another user)
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
if ( if (isThreadRunning || isBlockedByOtherUser) {
isThreadRunning ||
isBlockedByOtherUser ||
isUploadingDocs ||
blockingUploadedMentions.length > 0
) {
return; return;
} }
if (!showDocumentPopover) { if (!showDocumentPopover) {
composerRuntime.send(); composerRuntime.send();
editorRef.current?.clear(); editorRef.current?.clear();
setMentionedDocuments([]); setMentionedDocuments([]);
setMentionedDocumentIds({ setSidebarDocs([]);
surfsense_doc_ids: [],
document_ids: [],
});
} }
}, [ }, [
showDocumentPopover, showDocumentPopover,
isThreadRunning, isThreadRunning,
isBlockedByOtherUser, isBlockedByOtherUser,
isUploadingDocs,
blockingUploadedMentions.length,
composerRuntime, composerRuntime,
setMentionedDocuments, setMentionedDocuments,
setMentionedDocumentIds, setSidebarDocs,
]); ]);
// Remove document from mentions and sync IDs to atom
const handleDocumentRemove = useCallback( const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => { (docId: number, docType?: string) => {
setMentionedDocuments((prev) => { setMentionedDocuments((prev) =>
const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType)); prev.filter((doc) => !(doc.id === docId && doc.document_type === docType))
setMentionedDocumentIds({ );
surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated;
});
setUploadedMentionDocs((prev) => {
if (!(docId in prev)) return prev;
const { [docId]: _removed, ...rest } = prev;
return rest;
});
}, },
[setMentionedDocuments, setMentionedDocumentIds] [setMentionedDocuments]
); );
// Add selected documents from picker, insert chips, and sync IDs to atom
const handleDocumentsMention = useCallback( const handleDocumentsMention = useCallback(
(documents: Pick<Document, "id" | "title" | "document_type">[]) => { (documents: Pick<Document, "id" | "title" | "document_type">[]) => {
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)); const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
@ -486,185 +405,14 @@ const Composer: FC = () => {
const uniqueNewDocs = documents.filter( const uniqueNewDocs = documents.filter(
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`) (doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
); );
const updated = [...prev, ...uniqueNewDocs]; return [...prev, ...uniqueNewDocs];
setMentionedDocumentIds({
surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated;
}); });
setMentionQuery(""); setMentionQuery("");
}, },
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds] [mentionedDocuments, setMentionedDocuments]
); );
const refreshUploadedDocStatuses = useCallback(
async (documentIds: number[]) => {
if (!search_space_id || documentIds.length === 0) return;
const statusResponse = await documentsApiService.getDocumentsStatus({
queryParams: {
search_space_id: Number(search_space_id),
document_ids: documentIds,
},
});
setUploadedMentionDocs((prev) => {
const next = { ...prev };
for (const item of statusResponse.items) {
next[item.id] = {
id: item.id,
title: item.title,
document_type: item.document_type,
state: item.status.state,
reason: item.status.reason,
};
}
return next;
});
handleDocumentsMention(
statusResponse.items.map((item) => ({
id: item.id,
title: item.title,
document_type: item.document_type,
}))
);
},
[search_space_id, handleDocumentsMention]
);
const handleUploadClick = useCallback(() => {
if (isFileDialogOpenRef.current) return;
isFileDialogOpenRef.current = true;
uploadInputRef.current?.click();
// Reset after a delay to handle cancellation (which doesn't fire the change event).
setTimeout(() => {
isFileDialogOpenRef.current = false;
}, 1000);
}, []);
const handleUploadInputChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
isFileDialogOpenRef.current = false;
const files = Array.from(event.target.files ?? []);
event.target.value = "";
if (files.length === 0 || !search_space_id) return;
if (files.length > CHAT_MAX_FILES) {
toast.error(`Too many files. Maximum ${CHAT_MAX_FILES} files per upload.`);
return;
}
let totalSize = 0;
for (const file of files) {
if (file.size > CHAT_MAX_FILE_SIZE_BYTES) {
toast.error(
`File "${file.name}" (${(file.size / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB per-file limit.`
);
return;
}
totalSize += file.size;
}
if (totalSize > CHAT_MAX_TOTAL_SIZE_BYTES) {
toast.error(
`Total upload size (${(totalSize / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_TOTAL_SIZE_BYTES / (1024 * 1024)} MB limit.`
);
return;
}
setIsUploadingDocs(true);
try {
const uploadResponse = await documentsApiService.uploadDocument({
files,
search_space_id: Number(search_space_id),
});
const uploadedIds = uploadResponse.document_ids ?? [];
const duplicateIds = uploadResponse.duplicate_document_ids ?? [];
const idsToMention = Array.from(new Set([...uploadedIds, ...duplicateIds]));
if (idsToMention.length === 0) {
toast.warning("No documents were created or matched from selected files.");
return;
}
await refreshUploadedDocStatuses(idsToMention);
if (uploadedIds.length > 0 && duplicateIds.length > 0) {
toast.success(
`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""} and matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""}.`
);
} else if (uploadedIds.length > 0) {
toast.success(`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""}`);
} else {
toast.success(
`Matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""} and added mention${duplicateIds.length > 1 ? "s" : ""}.`
);
}
} catch (error) {
const message = error instanceof Error ? error.message : "Upload failed";
toast.error(`Upload failed: ${message}`);
} finally {
setIsUploadingDocs(false);
}
},
[search_space_id, refreshUploadedDocStatuses]
);
// Poll status for uploaded mentioned documents until all are ready or removed.
useEffect(() => {
const trackedIds = uploadedMentionedDocs.map((doc) => doc.id);
const needsPolling = trackedIds.some((id) => {
const state = uploadedMentionDocs[id]?.state;
return state === "pending" || state === "processing";
});
if (!needsPolling) return;
const interval = setInterval(() => {
refreshUploadedDocStatuses(trackedIds).catch((error) => {
console.error("[Composer] Failed to refresh uploaded mention statuses:", error);
});
}, 2500);
return () => clearInterval(interval);
}, [uploadedMentionedDocs, uploadedMentionDocs, refreshUploadedDocStatuses]);
// Push upload status directly onto mention chips (instead of separate status rows).
useEffect(() => {
for (const doc of uploadedMentionedDocs) {
const state = uploadedMentionDocs[doc.id]?.state ?? "pending";
const statusLabel =
state === "ready"
? null
: state === "failed"
? "failed"
: state === "processing"
? "indexing"
: "queued";
editorRef.current?.setDocumentChipStatus(doc.id, doc.document_type, statusLabel, state);
}
}, [uploadedMentionedDocs, uploadedMentionDocs]);
// Prune upload status entries that are no longer mentioned in the composer.
useEffect(() => {
const activeIds = new Set(mentionedDocuments.map((doc) => doc.id));
setUploadedMentionDocs((prev) => {
let changed = false;
const next: Record<number, UploadedMentionDoc> = {};
for (const [key, value] of Object.entries(prev)) {
const id = Number(key);
if (activeIds.has(id)) {
next[id] = value;
} else {
changed = true;
}
}
return changed ? next : prev;
});
}, [mentionedDocuments]);
return ( return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2"> <ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
<ChatSessionStatus <ChatSessionStatus
@ -688,15 +436,6 @@ const Composer: FC = () => {
className="min-h-[24px]" className="min-h-[24px]"
/> />
</div> </div>
<input
ref={uploadInputRef}
type="file"
multiple
accept={CHAT_UPLOAD_ACCEPT}
onChange={handleUploadInputChange}
className="hidden"
/>
{/* Document picker popover (portal to body for proper z-index stacking) */} {/* Document picker popover (portal to body for proper z-index stacking) */}
{showDocumentPopover && {showDocumentPopover &&
typeof document !== "undefined" && typeof document !== "undefined" &&
@ -722,15 +461,7 @@ const Composer: FC = () => {
/>, />,
document.body document.body
)} )}
<ComposerAction <ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
isBlockedByOtherUser={isBlockedByOtherUser}
onUploadClick={handleUploadClick}
isUploadingDocs={isUploadingDocs}
blockingUploadedMentionsCount={blockingUploadedMentions.length}
hasFailedUploadedMentions={blockingUploadedMentions.some(
(doc) => uploadedMentionDocs[doc.id]?.state === "failed"
)}
/>
</div> </div>
</ComposerPrimitive.Root> </ComposerPrimitive.Root>
); );
@ -738,29 +469,20 @@ const Composer: FC = () => {
interface ComposerActionProps { interface ComposerActionProps {
isBlockedByOtherUser?: boolean; isBlockedByOtherUser?: boolean;
onUploadClick: () => void;
isUploadingDocs: boolean;
blockingUploadedMentionsCount: number;
hasFailedUploadedMentions: boolean;
} }
const ComposerAction: FC<ComposerActionProps> = ({ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
isBlockedByOtherUser = false,
onUploadClick,
isUploadingDocs,
blockingUploadedMentionsCount,
hasFailedUploadedMentions,
}) => {
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom); const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const sidebarDocs = useAtomValue(sidebarSelectedDocumentsAtom);
const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom);
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
// Check if composer text is empty (chips are represented in mentionedDocuments atom)
const isComposerTextEmpty = useAssistantState(({ composer }) => { const isComposerTextEmpty = useAssistantState(({ composer }) => {
const text = composer.text?.trim() || ""; const text = composer.text?.trim() || "";
return text.length === 0; return text.length === 0;
}); });
const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0; const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0;
// Check if a model is configured
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom); const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom); const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
const { data: preferences } = useAtomValue(llmPreferencesAtom); const { data: preferences } = useAtomValue(llmPreferencesAtom);
@ -770,121 +492,91 @@ const ComposerAction: FC<ComposerActionProps> = ({
const agentLlmId = preferences.agent_llm_id; const agentLlmId = preferences.agent_llm_id;
if (agentLlmId === null || agentLlmId === undefined) return false; if (agentLlmId === null || agentLlmId === undefined) return false;
// Check if the configured model actually exists
// Auto mode (ID 0) and global configs (negative IDs) are in globalConfigs
if (agentLlmId <= 0) { if (agentLlmId <= 0) {
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false; return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
} }
return userConfigs?.some((c) => c.id === agentLlmId) ?? false; return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
}, [preferences, globalConfigs, userConfigs]); }, [preferences, globalConfigs, userConfigs]);
const isSendDisabled = const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
isComposerEmpty ||
!hasModelConfigured ||
isBlockedByOtherUser ||
isUploadingDocs ||
blockingUploadedMentionsCount > 0;
return ( return (
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between"> <div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<TooltipIconButton <TooltipIconButton
tooltip={ tooltip="Upload"
isUploadingDocs ? (
"Uploading documents..."
) : (
<div className="flex flex-col gap-0.5">
<span className="font-medium">Upload and mention files</span>
<span className="text-xs text-muted-foreground flex items-center">
Max 10 files <Dot className="size-3" /> 50 MB each
</span>
<span className="text-xs text-muted-foreground">Total upload limit: 200 MB</span>
</div>
)
}
side="bottom" side="bottom"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30" className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
aria-label="Upload files" aria-label="Upload documents"
onClick={onUploadClick} onClick={openUploadDialog}
disabled={isUploadingDocs}
> >
{isUploadingDocs ? ( <PlusIcon className="size-4" />
<Spinner size="sm" className="text-muted-foreground" />
) : (
<Paperclip className="size-4" />
)}
</TooltipIconButton> </TooltipIconButton>
<ConnectorIndicator /> <ConnectorIndicator />
</div> </div>
{blockingUploadedMentionsCount > 0 && ( {!hasModelConfigured && (
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
{hasFailedUploadedMentions ? <FileWarning className="size-3" /> : <Spinner size="xs" />}
<span>
{hasFailedUploadedMentions
? "Remove or retry failed uploads"
: "Waiting for uploaded files to finish indexing"}
</span>
</div>
)}
{/* Show warning when no model is configured */}
{!hasModelConfigured && blockingUploadedMentionsCount === 0 && (
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs"> <div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
<AlertCircle className="size-3" /> <AlertCircle className="size-3" />
<span>Select a model</span> <span>Select a model</span>
</div> </div>
)} )}
<AssistantIf condition={({ thread }) => !thread.isRunning}> <div className="flex items-center gap-2">
<ComposerPrimitive.Send asChild disabled={isSendDisabled}> {sidebarDocs.length > 0 && (
<TooltipIconButton <button
tooltip={
isBlockedByOtherUser
? "Wait for AI to finish responding"
: hasFailedUploadedMentions
? "Remove or retry failed uploads before sending"
: blockingUploadedMentionsCount > 0
? "Waiting for uploaded files to finish indexing"
: isUploadingDocs
? "Uploading documents..."
: !hasModelConfigured
? "Please select a model from the header to start chatting"
: isComposerEmpty
? "Enter a message to send"
: "Send message"
}
side="bottom"
type="submit"
variant="default"
size="icon"
className={cn(
"aui-composer-send size-8 rounded-full",
isSendDisabled && "cursor-not-allowed opacity-50"
)}
aria-label="Send message"
disabled={isSendDisabled}
>
<ArrowUpIcon className="aui-composer-send-icon size-4" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AssistantIf>
<AssistantIf condition={({ thread }) => thread.isRunning}>
<ComposerPrimitive.Cancel asChild>
<Button
type="button" type="button"
variant="default" onClick={() => setDocumentsSidebarOpen(true)}
size="icon" className="rounded-full border border-border/60 bg-accent/50 px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-accent"
className="aui-composer-cancel size-8 rounded-full"
aria-label="Stop generating"
> >
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" /> {sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
</Button> </button>
</ComposerPrimitive.Cancel> )}
</AssistantIf>
<AssistantIf condition={({ thread }) => !thread.isRunning}>
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
<TooltipIconButton
tooltip={
isBlockedByOtherUser
? "Wait for AI to finish responding"
: !hasModelConfigured
? "Please select a model from the header to start chatting"
: isComposerEmpty
? "Enter a message to send"
: "Send message"
}
side="bottom"
type="submit"
variant="default"
size="icon"
className={cn(
"aui-composer-send size-8 rounded-full",
isSendDisabled && "cursor-not-allowed opacity-50"
)}
aria-label="Send message"
disabled={isSendDisabled}
>
<ArrowUpIcon className="aui-composer-send-icon size-4" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AssistantIf>
<AssistantIf condition={({ thread }) => thread.isRunning}>
<ComposerPrimitive.Cancel asChild>
<Button
type="button"
variant="default"
size="icon"
className="aui-composer-cancel size-8 rounded-full"
aria-label="Stop generating"
>
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
</Button>
</ComposerPrimitive.Cancel>
</AssistantIf>
</div>
</div> </div>
); );
}; };

View file

@ -36,7 +36,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
)} )}
{canEdit && canDelete && <DropdownMenuSeparator />} {canEdit && canDelete && <DropdownMenuSeparator />}
{canDelete && ( {canDelete && (
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive"> <DropdownMenuItem onClick={onDelete}>
<Trash2 className="mr-2 size-4" /> <Trash2 className="mr-2 size-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>

View file

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

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { Plus, Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -76,7 +76,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa
<SelectTrigger id="param-key" className="w-full"> <SelectTrigger id="param-key" className="w-full">
<SelectValue placeholder="Select parameter" /> <SelectValue placeholder="Select parameter" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="bg-muted dark:border-neutral-700">
{PARAM_KEYS.map((key) => ( {PARAM_KEYS.map((key) => (
<SelectItem key={key} value={key}> <SelectItem key={key} value={key}>
{key} {key}
@ -104,7 +104,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa
onClick={handleAdd} onClick={handleAdd}
disabled={!selectedKey || value === ""} disabled={!selectedKey || value === ""}
> >
<Plus className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" /> Add Parameter Add Parameter
</Button> </Button>
</div> </div>

View file

@ -1,25 +1,28 @@
"use client"; "use client";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
AlertTriangle,
Inbox,
LogOut,
Megaphone,
PencilIcon,
SquareLibrary,
Trash2,
} from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation"; import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -32,6 +35,7 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types"; import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useAnnouncements } from "@/hooks/use-announcements"; import { useAnnouncements } from "@/hooks/use-announcements";
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
import { useInbox } from "@/hooks/use-inbox"; import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { logout } from "@/lib/auth-utils"; import { logout } from "@/lib/auth-utils";
@ -46,7 +50,6 @@ import { LayoutShell } from "../ui/shell";
interface LayoutDataProviderProps { interface LayoutDataProviderProps {
searchSpaceId: string; searchSpaceId: string;
children: React.ReactNode; children: React.ReactNode;
breadcrumb?: React.ReactNode;
} }
/** /**
@ -60,11 +63,7 @@ function formatInboxCount(count: number): string {
return `${thousands}k+`; return `${thousands}k+`;
} }
export function LayoutDataProvider({ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProviderProps) {
searchSpaceId,
children,
breadcrumb,
}: LayoutDataProviderProps) {
const t = useTranslations("dashboard"); const t = useTranslations("dashboard");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const tSidebar = useTranslations("sidebar"); const tSidebar = useTranslations("sidebar");
@ -114,40 +113,27 @@ export function LayoutDataProvider({
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false); const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
const [isInboxDocked, setIsInboxDocked] = useState(false); const [isInboxDocked, setIsInboxDocked] = useState(false);
// Documents sidebar state (shared atom so Composer can toggle it)
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
// Announcements sidebar state // Announcements sidebar state
const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false); const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false);
// Search space dialog state // Search space dialog state
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
// Inbox hooks - separate data sources for mentions and status tabs // Per-tab inbox hooks — each has independent API loading, pagination,
// This ensures each tab has independent pagination and data loading // and Electric live queries. The Electric sync shape is shared (client-level cache).
const userId = user?.id ? String(user.id) : null; const userId = user?.id ? String(user.id) : null;
const numericSpaceId = Number(searchSpaceId) || null;
const { const commentsInbox = useInbox(userId, numericSpaceId, "comments");
inboxItems: mentionItems, const statusInbox = useInbox(userId, numericSpaceId, "status");
unreadCount: mentionUnreadCount,
loading: mentionLoading,
loadingMore: mentionLoadingMore,
hasMore: mentionHasMore,
loadMore: mentionLoadMore,
markAsRead: markMentionAsRead,
markAllAsRead: markAllMentionsAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
const { const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount;
inboxItems: statusItems,
unreadCount: allUnreadCount,
loading: statusLoading,
loadingMore: statusLoadingMore,
hasMore: statusHasMore,
loadMore: statusLoadMore,
markAsRead: markStatusAsRead,
markAllAsRead: markAllStatusAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, null);
const totalUnreadCount = allUnreadCount; // Whether any documents are currently being uploaded/indexed — drives sidebar spinner
const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount); const isDocumentsProcessing = useDocumentsProcessing(numericSpaceId);
// Track seen notification IDs to detect new page_limit_exceeded notifications // Track seen notification IDs to detect new page_limit_exceeded notifications
const seenPageLimitNotifications = useRef<Set<number>>(new Set()); const seenPageLimitNotifications = useRef<Set<number>>(new Set());
@ -155,14 +141,12 @@ export function LayoutDataProvider({
// Effect to show toast for new page_limit_exceeded notifications // Effect to show toast for new page_limit_exceeded notifications
useEffect(() => { useEffect(() => {
if (statusLoading) return; if (statusInbox.loading) return;
// Get page_limit_exceeded notifications const pageLimitNotifications = statusInbox.inboxItems.filter(
const pageLimitNotifications = statusItems.filter(
(item) => item.type === "page_limit_exceeded" (item) => item.type === "page_limit_exceeded"
); );
// On initial load, just mark all as seen without showing toasts
if (isInitialLoad.current) { if (isInitialLoad.current) {
for (const notification of pageLimitNotifications) { for (const notification of pageLimitNotifications) {
seenPageLimitNotifications.current.add(notification.id); seenPageLimitNotifications.current.add(notification.id);
@ -171,16 +155,13 @@ export function LayoutDataProvider({
return; return;
} }
// Find new notifications (not yet seen)
const newNotifications = pageLimitNotifications.filter( const newNotifications = pageLimitNotifications.filter(
(notification) => !seenPageLimitNotifications.current.has(notification.id) (notification) => !seenPageLimitNotifications.current.has(notification.id)
); );
// Show toast for each new page_limit_exceeded notification
for (const notification of newNotifications) { for (const notification of newNotifications) {
seenPageLimitNotifications.current.add(notification.id); seenPageLimitNotifications.current.add(notification.id);
// Extract metadata for navigation
const actionUrl = isPageLimitExceededMetadata(notification.metadata) const actionUrl = isPageLimitExceededMetadata(notification.metadata)
? notification.metadata.action_url ? notification.metadata.action_url
: `/dashboard/${searchSpaceId}/more-pages`; : `/dashboard/${searchSpaceId}/more-pages`;
@ -195,24 +176,7 @@ export function LayoutDataProvider({
}, },
}); });
} }
}, [statusItems, statusLoading, searchSpaceId, router]); }, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]);
// Unified mark as read that delegates to the correct hook
const markAsRead = useCallback(
async (id: number) => {
// Try both - one will succeed based on which list has the item
const mentionResult = await markMentionAsRead(id);
if (mentionResult) return true;
return markStatusAsRead(id);
},
[markMentionAsRead, markStatusAsRead]
);
// Mark all as read for both types
const markAllAsRead = useCallback(async () => {
await Promise.all([markAllMentionsAsRead(), markAllStatusAsRead()]);
return true;
}, [markAllMentionsAsRead, markAllStatusAsRead]);
// Delete dialogs state // Delete dialogs state
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
@ -295,34 +259,35 @@ export function LayoutDataProvider({
// Navigation items // Navigation items
const navItems: NavItem[] = useMemo( const navItems: NavItem[] = useMemo(
() => [ () => [
{
title: "Documents",
url: `/dashboard/${searchSpaceId}/documents`,
icon: SquareLibrary,
isActive: pathname?.includes("/documents"),
},
{ {
title: "Inbox", title: "Inbox",
url: "#inbox", // Special URL to indicate this is handled differently url: "#inbox",
icon: Inbox, icon: Inbox,
isActive: isInboxSidebarOpen, isActive: isInboxSidebarOpen,
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined, badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
}, },
{
title: "Documents",
url: "#documents",
icon: SquareLibrary,
isActive: isDocumentsSidebarOpen,
showSpinner: isDocumentsProcessing,
},
{ {
title: "Announcements", title: "Announcements",
url: "#announcements", // Special URL to indicate this is handled differently url: "#announcements",
icon: Megaphone, icon: Megaphone,
isActive: isAnnouncementsSidebarOpen, isActive: isAnnouncementsSidebarOpen,
badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined, badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
}, },
], ],
[ [
searchSpaceId,
pathname,
isInboxSidebarOpen, isInboxSidebarOpen,
isDocumentsSidebarOpen,
totalUnreadCount, totalUnreadCount,
isAnnouncementsSidebarOpen, isAnnouncementsSidebarOpen,
announcementUnreadCount, announcementUnreadCount,
isDocumentsProcessing,
] ]
); );
@ -415,10 +380,22 @@ export function LayoutDataProvider({
const handleNavItemClick = useCallback( const handleNavItemClick = useCallback(
(item: NavItem) => { (item: NavItem) => {
// Handle inbox specially - toggle sidebar instead of navigating
if (item.url === "#inbox") { if (item.url === "#inbox") {
setIsInboxSidebarOpen((prev) => { setIsInboxSidebarOpen((prev) => {
if (!prev) { if (!prev) {
setIsAllSharedChatsSidebarOpen(false);
setIsAllPrivateChatsSidebarOpen(false);
setIsDocumentsSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false);
}
return !prev;
});
return;
}
if (item.url === "#documents") {
setIsDocumentsSidebarOpen((prev) => {
if (!prev) {
setIsInboxSidebarOpen(false);
setIsAllSharedChatsSidebarOpen(false); setIsAllSharedChatsSidebarOpen(false);
setIsAllPrivateChatsSidebarOpen(false); setIsAllPrivateChatsSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false);
@ -427,13 +404,13 @@ export function LayoutDataProvider({
}); });
return; return;
} }
// Handle announcements specially - toggle sidebar instead of navigating
if (item.url === "#announcements") { if (item.url === "#announcements") {
setIsAnnouncementsSidebarOpen((prev) => { setIsAnnouncementsSidebarOpen((prev) => {
if (!prev) { if (!prev) {
setIsInboxSidebarOpen(false); setIsInboxSidebarOpen(false);
setIsAllSharedChatsSidebarOpen(false); setIsAllSharedChatsSidebarOpen(false);
setIsAllPrivateChatsSidebarOpen(false); setIsAllPrivateChatsSidebarOpen(false);
setIsDocumentsSidebarOpen(false);
} }
return !prev; return !prev;
}); });
@ -441,13 +418,7 @@ export function LayoutDataProvider({
} }
router.push(item.url); router.push(item.url);
}, },
[ [router, setIsDocumentsSidebarOpen]
router,
setIsAllPrivateChatsSidebarOpen,
setIsAllSharedChatsSidebarOpen,
setIsAnnouncementsSidebarOpen,
setIsInboxSidebarOpen,
]
); );
const handleNewChat = useCallback(() => { const handleNewChat = useCallback(() => {
@ -544,15 +515,17 @@ export function LayoutDataProvider({
setIsAllSharedChatsSidebarOpen(true); setIsAllSharedChatsSidebarOpen(true);
setIsAllPrivateChatsSidebarOpen(false); setIsAllPrivateChatsSidebarOpen(false);
setIsInboxSidebarOpen(false); setIsInboxSidebarOpen(false);
setIsDocumentsSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false);
}, []); }, [setIsDocumentsSidebarOpen]);
const handleViewAllPrivateChats = useCallback(() => { const handleViewAllPrivateChats = useCallback(() => {
setIsAllPrivateChatsSidebarOpen(true); setIsAllPrivateChatsSidebarOpen(true);
setIsAllSharedChatsSidebarOpen(false); setIsAllSharedChatsSidebarOpen(false);
setIsInboxSidebarOpen(false); setIsInboxSidebarOpen(false);
setIsDocumentsSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false);
}, []); }, [setIsDocumentsSidebarOpen]);
// Delete handlers // Delete handlers
const confirmDeleteChat = useCallback(async () => { const confirmDeleteChat = useCallback(async () => {
@ -583,10 +556,6 @@ export function LayoutDataProvider({
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
// Invalidate thread detail for breadcrumb update
queryClient.invalidateQueries({
queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)],
});
} catch (error) { } catch (error) {
console.error("Error renaming thread:", error); console.error("Error renaming thread:", error);
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat"); toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
@ -641,7 +610,6 @@ export function LayoutDataProvider({
onUserSettings={handleUserSettings} onUserSettings={handleUserSettings}
onLogout={handleLogout} onLogout={handleLogout}
pageUsage={pageUsage} pageUsage={pageUsage}
breadcrumb={breadcrumb}
theme={theme} theme={theme}
setTheme={setTheme} setTheme={setTheme}
isChatPage={isChatPage} isChatPage={isChatPage}
@ -649,26 +617,27 @@ export function LayoutDataProvider({
inbox={{ inbox={{
isOpen: isInboxSidebarOpen, isOpen: isInboxSidebarOpen,
onOpenChange: setIsInboxSidebarOpen, onOpenChange: setIsInboxSidebarOpen,
// Separate data sources for each tab totalUnreadCount,
mentions: { comments: {
items: mentionItems, items: commentsInbox.inboxItems,
unreadCount: mentionUnreadCount, unreadCount: commentsInbox.unreadCount,
loading: mentionLoading, loading: commentsInbox.loading,
loadingMore: mentionLoadingMore, loadingMore: commentsInbox.loadingMore,
hasMore: mentionHasMore, hasMore: commentsInbox.hasMore,
loadMore: mentionLoadMore, loadMore: commentsInbox.loadMore,
markAsRead: commentsInbox.markAsRead,
markAllAsRead: commentsInbox.markAllAsRead,
}, },
status: { status: {
items: statusItems, items: statusInbox.inboxItems,
unreadCount: statusOnlyUnreadCount, unreadCount: statusInbox.unreadCount,
loading: statusLoading, loading: statusInbox.loading,
loadingMore: statusLoadingMore, loadingMore: statusInbox.loadingMore,
hasMore: statusHasMore, hasMore: statusInbox.hasMore,
loadMore: statusLoadMore, loadMore: statusInbox.loadMore,
markAsRead: statusInbox.markAsRead,
markAllAsRead: statusInbox.markAllAsRead,
}, },
totalUnreadCount,
markAsRead,
markAllAsRead,
isDocked: isInboxDocked, isDocked: isInboxDocked,
onDockedChange: setIsInboxDocked, onDockedChange: setIsInboxDocked,
}} }}
@ -686,36 +655,33 @@ export function LayoutDataProvider({
onOpenChange: setIsAllPrivateChatsSidebarOpen, onOpenChange: setIsAllPrivateChatsSidebarOpen,
searchSpaceId, searchSpaceId,
}} }}
documentsPanel={{
open: isDocumentsSidebarOpen,
onOpenChange: setIsDocumentsSidebarOpen,
}}
> >
{children} {children}
</LayoutShell> </LayoutShell>
{/* Delete Chat Dialog */} {/* Delete Chat Dialog */}
<Dialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}> <AlertDialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}>
<DialogContent className="sm:max-w-md"> <AlertDialogContent className="sm:max-w-md">
<DialogHeader> <AlertDialogHeader>
<DialogTitle className="flex items-center gap-2"> <AlertDialogTitle>{t("delete_chat")}</AlertDialogTitle>
<Trash2 className="h-5 w-5 text-destructive" /> <AlertDialogDescription>
<span>{t("delete_chat")}</span>
</DialogTitle>
<DialogDescription>
{t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "} {t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "}
{t("action_cannot_undone")} {t("action_cannot_undone")}
</DialogDescription> </AlertDialogDescription>
</DialogHeader> </AlertDialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end"> <AlertDialogFooter>
<Button <AlertDialogCancel disabled={isDeletingChat}>{tCommon("cancel")}</AlertDialogCancel>
variant="outline" <AlertDialogAction
onClick={() => setShowDeleteChatDialog(false)} onClick={(e) => {
e.preventDefault();
confirmDeleteChat();
}}
disabled={isDeletingChat} disabled={isDeletingChat}
> className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
{tCommon("cancel")}
</Button>
<Button
variant="destructive"
onClick={confirmDeleteChat}
disabled={isDeletingChat}
className="gap-2"
> >
{isDeletingChat ? ( {isDeletingChat ? (
<> <>
@ -723,15 +689,12 @@ export function LayoutDataProvider({
{t("deleting")} {t("deleting")}
</> </>
) : ( ) : (
<> tCommon("delete")
<Trash2 className="h-4 w-4" />
{tCommon("delete")}
</>
)} )}
</Button> </AlertDialogAction>
</DialogFooter> </AlertDialogFooter>
</DialogContent> </AlertDialogContent>
</Dialog> </AlertDialog>
{/* Rename Chat Dialog */} {/* Rename Chat Dialog */}
<Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}> <Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}>
@ -756,7 +719,7 @@ export function LayoutDataProvider({
/> />
<DialogFooter className="flex gap-2 sm:justify-end"> <DialogFooter className="flex gap-2 sm:justify-end">
<Button <Button
variant="outline" variant="secondary"
onClick={() => setShowRenameChatDialog(false)} onClick={() => setShowRenameChatDialog(false)}
disabled={isRenamingChat} disabled={isRenamingChat}
> >
@ -773,10 +736,7 @@ export function LayoutDataProvider({
{tSidebar("renaming") || "Renaming"} {tSidebar("renaming") || "Renaming"}
</> </>
) : ( ) : (
<> tSidebar("rename") || "Rename"
<PencilIcon className="h-4 w-4" />
{tSidebar("rename") || "Rename"}
</>
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
@ -784,30 +744,25 @@ export function LayoutDataProvider({
</Dialog> </Dialog>
{/* Delete Search Space Dialog */} {/* Delete Search Space Dialog */}
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}> <AlertDialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
<DialogContent className="sm:max-w-md"> <AlertDialogContent className="sm:max-w-md">
<DialogHeader> <AlertDialogHeader>
<DialogTitle className="flex items-center gap-2"> <AlertDialogTitle>{t("delete_search_space")}</AlertDialogTitle>
<Trash2 className="h-5 w-5 text-destructive" /> <AlertDialogDescription>
<span>{t("delete_search_space")}</span>
</DialogTitle>
<DialogDescription>
{t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })} {t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })}
</DialogDescription> </AlertDialogDescription>
</DialogHeader> </AlertDialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end"> <AlertDialogFooter>
<Button <AlertDialogCancel disabled={isDeletingSearchSpace}>
variant="outline"
onClick={() => setShowDeleteSearchSpaceDialog(false)}
disabled={isDeletingSearchSpace}
>
{tCommon("cancel")} {tCommon("cancel")}
</Button> </AlertDialogCancel>
<Button <AlertDialogAction
variant="destructive" onClick={(e) => {
onClick={confirmDeleteSearchSpace} e.preventDefault();
confirmDeleteSearchSpace();
}}
disabled={isDeletingSearchSpace} disabled={isDeletingSearchSpace}
className="gap-2" className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
> >
{isDeletingSearchSpace ? ( {isDeletingSearchSpace ? (
<> <>
@ -815,41 +770,33 @@ export function LayoutDataProvider({
{t("deleting")} {t("deleting")}
</> </>
) : ( ) : (
<> tCommon("delete")
<Trash2 className="h-4 w-4" />
{tCommon("delete")}
</>
)} )}
</Button> </AlertDialogAction>
</DialogFooter> </AlertDialogFooter>
</DialogContent> </AlertDialogContent>
</Dialog> </AlertDialog>
{/* Leave Search Space Dialog */} {/* Leave Search Space Dialog */}
<Dialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}> <AlertDialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
<DialogContent className="sm:max-w-md"> <AlertDialogContent className="sm:max-w-md">
<DialogHeader> <AlertDialogHeader>
<DialogTitle className="flex items-center gap-2"> <AlertDialogTitle>{t("leave_title")}</AlertDialogTitle>
<LogOut className="h-5 w-5 text-destructive" /> <AlertDialogDescription>
<span>{t("leave_title")}</span>
</DialogTitle>
<DialogDescription>
{t("leave_confirm", { name: searchSpaceToLeave?.name || "" })} {t("leave_confirm", { name: searchSpaceToLeave?.name || "" })}
</DialogDescription> </AlertDialogDescription>
</DialogHeader> </AlertDialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end"> <AlertDialogFooter>
<Button <AlertDialogCancel disabled={isLeavingSearchSpace}>
variant="outline"
onClick={() => setShowLeaveSearchSpaceDialog(false)}
disabled={isLeavingSearchSpace}
>
{tCommon("cancel")} {tCommon("cancel")}
</Button> </AlertDialogCancel>
<Button <AlertDialogAction
variant="destructive" onClick={(e) => {
onClick={confirmLeaveSearchSpace} e.preventDefault();
confirmLeaveSearchSpace();
}}
disabled={isLeavingSearchSpace} disabled={isLeavingSearchSpace}
className="gap-2" className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
> >
{isLeavingSearchSpace ? ( {isLeavingSearchSpace ? (
<> <>
@ -857,15 +804,12 @@ export function LayoutDataProvider({
{t("leaving")} {t("leaving")}
</> </>
) : ( ) : (
<> t("leave")
<LogOut className="h-4 w-4" />
{t("leave")}
</>
)} )}
</Button> </AlertDialogAction>
</DialogFooter> </AlertDialogFooter>
</DialogContent> </AlertDialogContent>
</Dialog> </AlertDialog>
{/* Create Search Space Dialog */} {/* Create Search Space Dialog */}
<CreateSearchSpaceDialog <CreateSearchSpaceDialog

View file

@ -21,6 +21,7 @@ export interface NavItem {
icon: LucideIcon; icon: LucideIcon;
isActive?: boolean; isActive?: boolean;
badge?: string | number; badge?: string | number;
showSpinner?: boolean;
} }
export interface ChatItem { export interface ChatItem {

View file

@ -138,20 +138,20 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
)} )}
/> />
<DialogFooter className="flex-row gap-2 pt-2 sm:pt-3"> <DialogFooter className="flex-row justify-end gap-2 pt-2 sm:pt-3">
<Button <Button
type="button" type="button"
variant="outline" variant="secondary"
onClick={() => handleOpenChange(false)} onClick={() => handleOpenChange(false)}
disabled={isSubmitting} disabled={isSubmitting}
className="flex-1 sm:flex-none sm:w-auto h-8 sm:h-10 text-xs sm:text-sm" className="h-8 sm:h-9 text-xs sm:text-sm"
> >
{tCommon("cancel")} {tCommon("cancel")}
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="flex-1 sm:flex-none sm:w-auto h-8 sm:h-10 text-xs sm:text-sm" className="h-8 sm:h-9 text-xs sm:text-sm"
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>

View file

@ -3,33 +3,30 @@
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { ChatHeader } from "@/components/new-chat/chat-header";
import { ChatShareButton } from "@/components/new-chat/chat-share-button"; import { ChatShareButton } from "@/components/new-chat/chat-share-button";
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence"; import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
interface HeaderProps { interface HeaderProps {
breadcrumb?: React.ReactNode;
mobileMenuTrigger?: React.ReactNode; mobileMenuTrigger?: React.ReactNode;
} }
export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) { export function Header({ mobileMenuTrigger }: HeaderProps) {
const pathname = usePathname(); const pathname = usePathname();
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
// Check if we're on a chat page
const isChatPage = pathname?.includes("/new-chat") ?? false; const isChatPage = pathname?.includes("/new-chat") ?? false;
// Use Jotai atom for thread state (synced from chat page)
const currentThreadState = useAtomValue(currentThreadAtom); const currentThreadState = useAtomValue(currentThreadAtom);
// Show button only when we have a thread id (thread exists and is synced to Jotai)
const hasThread = isChatPage && currentThreadState.id !== null; const hasThread = isChatPage && currentThreadState.id !== null;
// Create minimal thread object for ChatShareButton (used for API calls)
const threadForButton: ThreadRecord | null = const threadForButton: ThreadRecord | null =
hasThread && currentThreadState.id !== null hasThread && currentThreadState.id !== null
? { ? {
id: currentThreadState.id, id: currentThreadState.id,
visibility: currentThreadState.visibility ?? "PRIVATE", visibility: currentThreadState.visibility ?? "PRIVATE",
// These fields are not used by ChatShareButton for display, only for checks
created_by_id: null, created_by_id: null,
search_space_id: 0, search_space_id: 0,
title: "", title: "",
@ -39,22 +36,20 @@ export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
} }
: null; : null;
const handleVisibilityChange = (_visibility: ChatVisibility) => { const handleVisibilityChange = (_visibility: ChatVisibility) => {};
// Visibility change is handled by ChatShareButton internally via Jotai
// This callback can be used for additional side effects if needed
};
return ( return (
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4"> <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4 md:border-b md:border-border">
{/* Left side - Mobile menu trigger + Breadcrumb */} {/* Left side - Mobile menu trigger + Model selector */}
<div className="flex flex-1 items-center gap-2 min-w-0"> <div className="flex flex-1 items-center gap-2 min-w-0">
{mobileMenuTrigger} {mobileMenuTrigger}
<div className="hidden md:block">{breadcrumb}</div> {isChatPage && searchSpaceId && (
<ChatHeader searchSpaceId={Number(searchSpaceId)} className="md:h-9 md:px-4 md:text-sm" />
)}
</div> </div>
{/* Right side - Actions */} {/* Right side - Actions */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Share button - only show on chat pages when thread exists */}
{hasThread && ( {hasThread && (
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} /> <ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
)} )}

View file

@ -159,13 +159,13 @@ export function SearchSpaceAvatar({
)} )}
{onSettings && onDelete && <DropdownMenuSeparator />} {onSettings && onDelete && <DropdownMenuSeparator />}
{onDelete && isOwner && ( {onDelete && isOwner && (
<DropdownMenuItem variant="destructive" onClick={onDelete}> <DropdownMenuItem onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")} {tCommon("delete")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{onDelete && !isOwner && ( {onDelete && !isOwner && (
<DropdownMenuItem variant="destructive" onClick={onDelete}> <DropdownMenuItem onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{t("leave")} {t("leave")}
</DropdownMenuItem> </DropdownMenuItem>
@ -217,13 +217,13 @@ export function SearchSpaceAvatar({
)} )}
{onSettings && onDelete && <ContextMenuSeparator />} {onSettings && onDelete && <ContextMenuSeparator />}
{onDelete && isOwner && ( {onDelete && isOwner && (
<ContextMenuItem variant="destructive" onClick={onDelete}> <ContextMenuItem onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")} {tCommon("delete")}
</ContextMenuItem> </ContextMenuItem>
)} )}
{onDelete && !isOwner && ( {onDelete && !isOwner && (
<ContextMenuItem variant="destructive" onClick={onDelete}> <ContextMenuItem onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{t("leave")} {t("leave")}
</ContextMenuItem> </ContextMenuItem>

View file

@ -14,37 +14,33 @@ import {
AllPrivateChatsSidebar, AllPrivateChatsSidebar,
AllSharedChatsSidebar, AllSharedChatsSidebar,
AnnouncementsSidebar, AnnouncementsSidebar,
DocumentsSidebar,
InboxSidebar, InboxSidebar,
MobileSidebar, MobileSidebar,
MobileSidebarTrigger, MobileSidebarTrigger,
Sidebar, Sidebar,
} from "../sidebar"; } from "../sidebar";
// Tab-specific data source props // Per-tab data source
interface TabDataSource { interface TabDataSource {
items: InboxItem[]; items: InboxItem[];
unreadCount: number; unreadCount: number;
loading: boolean; loading: boolean;
loadingMore?: boolean; loadingMore: boolean;
hasMore?: boolean; hasMore: boolean;
loadMore?: () => void; loadMore: () => void;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
} }
// Inbox-related props with separate data sources per tab // Inbox-related props — per-tab data sources with independent loading/pagination
interface InboxProps { interface InboxProps {
isOpen: boolean; isOpen: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** Mentions tab data source with independent pagination */
mentions: TabDataSource;
/** Status tab data source with independent pagination */
status: TabDataSource;
/** Combined unread count for nav badge */
totalUnreadCount: number; totalUnreadCount: number;
markAsRead: (id: number) => Promise<boolean>; comments: TabDataSource;
markAllAsRead: () => Promise<boolean>; status: TabDataSource;
/** Whether the inbox is docked (permanent) */
isDocked?: boolean; isDocked?: boolean;
/** Callback to change docked state */
onDockedChange?: (docked: boolean) => void; onDockedChange?: (docked: boolean) => void;
} }
@ -74,7 +70,6 @@ interface LayoutShellProps {
onUserSettings?: () => void; onUserSettings?: () => void;
onLogout?: () => void; onLogout?: () => void;
pageUsage?: PageUsage; pageUsage?: PageUsage;
breadcrumb?: React.ReactNode;
theme?: string; theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void; setTheme?: (theme: "light" | "dark" | "system") => void;
defaultCollapsed?: boolean; defaultCollapsed?: boolean;
@ -99,6 +94,10 @@ interface LayoutShellProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
searchSpaceId: string; searchSpaceId: string;
}; };
documentsPanel?: {
open: boolean;
onOpenChange: (open: boolean) => void;
};
} }
export function LayoutShell({ export function LayoutShell({
@ -127,7 +126,6 @@ export function LayoutShell({
onUserSettings, onUserSettings,
onLogout, onLogout,
pageUsage, pageUsage,
breadcrumb,
theme, theme,
setTheme, setTheme,
defaultCollapsed = false, defaultCollapsed = false,
@ -139,6 +137,7 @@ export function LayoutShell({
isLoadingChats = false, isLoadingChats = false,
allSharedChatsPanel, allSharedChatsPanel,
allPrivateChatsPanel, allPrivateChatsPanel,
documentsPanel,
}: LayoutShellProps) { }: LayoutShellProps) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@ -162,7 +161,6 @@ export function LayoutShell({
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<div className={cn("flex h-screen w-full flex-col bg-background", className)}> <div className={cn("flex h-screen w-full flex-col bg-background", className)}>
<Header <Header
breadcrumb={breadcrumb}
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />} mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
/> />
@ -208,16 +206,22 @@ export function LayoutShell({
<InboxSidebar <InboxSidebar
open={inbox.isOpen} open={inbox.isOpen}
onOpenChange={inbox.onOpenChange} onOpenChange={inbox.onOpenChange}
mentions={inbox.mentions} comments={inbox.comments}
status={inbox.status} status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount} totalUnreadCount={inbox.totalUnreadCount}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
onCloseMobileSidebar={() => setMobileMenuOpen(false)} onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/> />
)} )}
{/* Mobile Announcements Sidebar - only render when open to avoid scroll blocking */} {/* Mobile Documents Sidebar - slide-out panel */}
{documentsPanel && (
<DocumentsSidebar
open={documentsPanel.open}
onOpenChange={documentsPanel.onOpenChange}
/>
)}
{/* Mobile Announcements Sidebar */}
{announcementsPanel?.open && ( {announcementsPanel?.open && (
<AnnouncementsSidebar <AnnouncementsSidebar
open={announcementsPanel.open} open={announcementsPanel.open}
@ -307,18 +311,16 @@ export function LayoutShell({
<InboxSidebar <InboxSidebar
open={inbox.isOpen} open={inbox.isOpen}
onOpenChange={inbox.onOpenChange} onOpenChange={inbox.onOpenChange}
mentions={inbox.mentions} comments={inbox.comments}
status={inbox.status} status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount} totalUnreadCount={inbox.totalUnreadCount}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
isDocked={inbox.isDocked} isDocked={inbox.isDocked}
onDockedChange={inbox.onDockedChange} onDockedChange={inbox.onDockedChange}
/> />
)} )}
<main className="flex-1 flex flex-col min-w-0"> <main className="flex-1 flex flex-col min-w-0">
<Header breadcrumb={breadcrumb} /> <Header />
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}> <div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children} {children}
@ -330,17 +332,23 @@ export function LayoutShell({
<InboxSidebar <InboxSidebar
open={inbox.isOpen} open={inbox.isOpen}
onOpenChange={inbox.onOpenChange} onOpenChange={inbox.onOpenChange}
mentions={inbox.mentions} comments={inbox.comments}
status={inbox.status} status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount} totalUnreadCount={inbox.totalUnreadCount}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
isDocked={false} isDocked={false}
onDockedChange={inbox.onDockedChange} onDockedChange={inbox.onDockedChange}
/> />
)} )}
{/* Announcements Sidebar - positioned absolutely on top of content */} {/* Documents Sidebar - slide-out panel */}
{documentsPanel && (
<DocumentsSidebar
open={documentsPanel.open}
onOpenChange={documentsPanel.onOpenChange}
/>
)}
{/* Announcements Sidebar */}
{announcementsPanel && ( {announcementsPanel && (
<AnnouncementsSidebar <AnnouncementsSidebar
open={announcementsPanel.open} open={announcementsPanel.open}

View file

@ -16,7 +16,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -40,6 +40,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { import {
deleteThread, deleteThread,
@ -85,6 +86,15 @@ export function AllPrivateChatsSidebar({
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
const pendingThreadIdRef = useRef<number | null>(null);
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
useCallback(() => {
if (pendingThreadIdRef.current !== null) {
setOpenDropdownId(pendingThreadIdRef.current);
}
}, [])
);
const isSearchMode = !!debouncedSearchQuery.trim(); const isSearchMode = !!debouncedSearchQuery.trim();
useEffect(() => { useEffect(() => {
@ -357,7 +367,16 @@ export function AllPrivateChatsSidebar({
{isMobile ? ( {isMobile ? (
<button <button
type="button" type="button"
onClick={() => handleThreadClick(thread.id)} onClick={() => {
if (wasLongPress()) return;
handleThreadClick(thread.id);
}}
onTouchStart={() => {
pendingThreadIdRef.current = thread.id;
longPressHandlers.onTouchStart();
}}
onTouchEnd={longPressHandlers.onTouchEnd}
onTouchMove={longPressHandlers.onTouchMove}
disabled={isBusy} disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden" className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
> >
@ -396,7 +415,9 @@ export function AllPrivateChatsSidebar({
size="icon" size="icon"
className={cn( className={cn(
"h-6 w-6 shrink-0", "h-6 w-6 shrink-0",
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100", isMobile
? "opacity-0 pointer-events-none absolute"
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
"transition-opacity" "transition-opacity"
)} )}
disabled={isBusy} disabled={isBusy}
@ -435,10 +456,7 @@ export function AllPrivateChatsSidebar({
)} )}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
onClick={() => handleDeleteThread(thread.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span> <span>{t("delete") || "Delete"}</span>
</DropdownMenuItem> </DropdownMenuItem>
@ -496,7 +514,7 @@ export function AllPrivateChatsSidebar({
/> />
<DialogFooter className="flex gap-2 sm:justify-end"> <DialogFooter className="flex gap-2 sm:justify-end">
<Button <Button
variant="outline" variant="secondary"
onClick={() => setShowRenameDialog(false)} onClick={() => setShowRenameDialog(false)}
disabled={isRenaming} disabled={isRenaming}
> >

View file

@ -16,7 +16,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -40,6 +40,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { import {
deleteThread, deleteThread,
@ -85,6 +86,15 @@ export function AllSharedChatsSidebar({
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
const pendingThreadIdRef = useRef<number | null>(null);
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
useCallback(() => {
if (pendingThreadIdRef.current !== null) {
setOpenDropdownId(pendingThreadIdRef.current);
}
}, [])
);
const isSearchMode = !!debouncedSearchQuery.trim(); const isSearchMode = !!debouncedSearchQuery.trim();
useEffect(() => { useEffect(() => {
@ -357,7 +367,16 @@ export function AllSharedChatsSidebar({
{isMobile ? ( {isMobile ? (
<button <button
type="button" type="button"
onClick={() => handleThreadClick(thread.id)} onClick={() => {
if (wasLongPress()) return;
handleThreadClick(thread.id);
}}
onTouchStart={() => {
pendingThreadIdRef.current = thread.id;
longPressHandlers.onTouchStart();
}}
onTouchEnd={longPressHandlers.onTouchEnd}
onTouchMove={longPressHandlers.onTouchMove}
disabled={isBusy} disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden" className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
> >
@ -396,7 +415,9 @@ export function AllSharedChatsSidebar({
size="icon" size="icon"
className={cn( className={cn(
"h-6 w-6 shrink-0", "h-6 w-6 shrink-0",
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100", isMobile
? "opacity-0 pointer-events-none absolute"
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
"transition-opacity" "transition-opacity"
)} )}
disabled={isBusy} disabled={isBusy}
@ -435,10 +456,7 @@ export function AllSharedChatsSidebar({
)} )}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
onClick={() => handleDeleteThread(thread.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span> <span>{t("delete") || "Delete"}</span>
</DropdownMenuItem> </DropdownMenuItem>
@ -496,7 +514,7 @@ export function AllSharedChatsSidebar({
/> />
<DialogFooter className="flex gap-2 sm:justify-end"> <DialogFooter className="flex gap-2 sm:justify-end">
<Button <Button
variant="outline" variant="secondary"
onClick={() => setShowRenameDialog(false)} onClick={() => setShowRenameDialog(false)}
disabled={isRenaming} disabled={isRenaming}
> >

View file

@ -2,8 +2,8 @@
import { ChevronLeft } from "lucide-react"; import { ChevronLeft } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
import { AnnouncementCard } from "@/components/announcements/AnnouncementCard"; import { AnnouncementCard } from "@/components/announcements/AnnouncementCard";
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAnnouncements } from "@/hooks/use-announcements"; import { useAnnouncements } from "@/hooks/use-announcements";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
@ -72,4 +72,3 @@ export function AnnouncementsSidebar({
</SidebarSlideOutPanel> </SidebarSlideOutPanel>
); );
} }

View file

@ -9,6 +9,7 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@ -17,6 +18,8 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface ChatListItemProps { interface ChatListItemProps {
@ -39,12 +42,24 @@ export function ChatListItem({
onDelete, onDelete,
}: ChatListItemProps) { }: ChatListItemProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const isMobile = useIsMobile();
const [dropdownOpen, setDropdownOpen] = useState(false);
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
useCallback(() => setDropdownOpen(true), [])
);
const handleClick = useCallback(() => {
if (wasLongPress()) return;
onClick?.();
}, [onClick, wasLongPress]);
return ( return (
<div className="group/item relative w-full"> <div className="group/item relative w-full">
<button <button
type="button" type="button"
onClick={onClick} onClick={handleClick}
{...(isMobile ? longPressHandlers : {})}
className={cn( className={cn(
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors", "flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
"[&>span:last-child]:truncate", "[&>span:last-child]:truncate",
@ -57,9 +72,14 @@ export function ChatListItem({
<span className="w-[calc(100%-3rem)] ">{name}</span> <span className="w-[calc(100%-3rem)] ">{name}</span>
</button> </button>
{/* Actions dropdown */} {/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-100 md:opacity-0 md:group-hover/item:opacity-100 transition-opacity"> <div
<DropdownMenu> className={cn(
"absolute right-1 top-1/2 -translate-y-1/2 transition-opacity",
isMobile ? "opacity-0 pointer-events-none" : "opacity-0 group-hover/item:opacity-100"
)}
>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6"> <Button variant="ghost" size="icon" className="h-6 w-6">
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" /> <MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
@ -105,7 +125,6 @@ export function ChatListItem({
e.stopPropagation(); e.stopPropagation();
onDelete(); onDelete();
}} }}
className="text-destructive focus:text-destructive"
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete")}</span> <span>{t("delete")}</span>

View 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>
);
}

View file

@ -22,6 +22,7 @@ import {
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@ -49,6 +50,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { import {
isCommentReplyMetadata, isCommentReplyMetadata,
isConnectorIndexingMetadata, isConnectorIndexingMetadata,
isDocumentProcessingMetadata,
isNewMentionMetadata, isNewMentionMetadata,
isPageLimitExceededMetadata, isPageLimitExceededMetadata,
} from "@/contracts/types/inbox.types"; } from "@/contracts/types/inbox.types";
@ -60,9 +62,6 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
/**
* Get initials from name or email for avatar fallback
*/
function getInitials(name: string | null | undefined, email: string | null | undefined): string { function getInitials(name: string | null | undefined, email: string | null | undefined): string {
if (name) { if (name) {
return name return name
@ -79,9 +78,6 @@ function getInitials(name: string | null | undefined, email: string | null | und
return "U"; return "U";
} }
/**
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
*/
function formatInboxCount(count: number): string { function formatInboxCount(count: number): string {
if (count <= 999) { if (count <= 999) {
return count.toString(); return count.toString();
@ -90,9 +86,6 @@ function formatInboxCount(count: number): string {
return `${thousands}k+`; return `${thousands}k+`;
} }
/**
* Get display name for connector type
*/
function getConnectorTypeDisplayName(connectorType: string): string { function getConnectorTypeDisplayName(connectorType: string): string {
const displayNames: Record<string, string> = { const displayNames: Record<string, string> = {
GITHUB_CONNECTOR: "GitHub", GITHUB_CONNECTOR: "GitHub",
@ -135,44 +128,36 @@ function getConnectorTypeDisplayName(connectorType: string): string {
} }
type InboxTab = "comments" | "status"; type InboxTab = "comments" | "status";
type InboxFilter = "all" | "unread"; type InboxFilter = "all" | "unread" | "errors";
// Tab-specific data source with independent pagination
interface TabDataSource { interface TabDataSource {
items: InboxItem[]; items: InboxItem[];
unreadCount: number; unreadCount: number;
loading: boolean; loading: boolean;
loadingMore?: boolean; loadingMore: boolean;
hasMore?: boolean; hasMore: boolean;
loadMore?: () => void; loadMore: () => void;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
} }
interface InboxSidebarProps { interface InboxSidebarProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** Mentions tab data source with independent pagination */ comments: TabDataSource;
mentions: TabDataSource;
/** Status tab data source with independent pagination */
status: TabDataSource; status: TabDataSource;
/** Combined unread count for mark all as read */
totalUnreadCount: number; totalUnreadCount: number;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
onCloseMobileSidebar?: () => void; onCloseMobileSidebar?: () => void;
/** Whether the inbox is docked (permanent) or floating */
isDocked?: boolean; isDocked?: boolean;
/** Callback to toggle docked state */
onDockedChange?: (docked: boolean) => void; onDockedChange?: (docked: boolean) => void;
} }
export function InboxSidebar({ export function InboxSidebar({
open, open,
onOpenChange, onOpenChange,
mentions, comments,
status, status,
totalUnreadCount, totalUnreadCount,
markAsRead,
markAllAsRead,
onCloseMobileSidebar, onCloseMobileSidebar,
isDocked = false, isDocked = false,
onDockedChange, onDockedChange,
@ -183,9 +168,7 @@ export function InboxSidebar({
const isMobile = !useMediaQuery("(min-width: 640px)"); const isMobile = !useMediaQuery("(min-width: 640px)");
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null; const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
// Comments collapsed state (desktop only, when docked)
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom); const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
// Target comment for navigation - also ensures comments panel is visible
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -193,11 +176,9 @@ export function InboxSidebar({
const isSearchMode = !!debouncedSearch.trim(); const isSearchMode = !!debouncedSearch.trim();
const [activeTab, setActiveTab] = useState<InboxTab>("comments"); const [activeTab, setActiveTab] = useState<InboxTab>("comments");
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all"); const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
const [selectedConnector, setSelectedConnector] = useState<string | null>(null); const [selectedSource, setSelectedSource] = useState<string | null>(null);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
// Dropdown state for filter menu (desktop only)
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null); const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
// Scroll shadow state for connector list
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top"); const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget; const el = e.currentTarget;
@ -205,15 +186,12 @@ export function InboxSidebar({
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []); }, []);
// Drawer state for filter menu (mobile only)
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null); const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
// Prefetch trigger ref - placed on item near the end
const prefetchTriggerRef = useRef<HTMLDivElement>(null); const prefetchTriggerRef = useRef<HTMLDivElement>(null);
// Server-side search query (enabled only when user is typing a search) // Server-side search query
// Determines which notification types to search based on active tab
const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined; const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined;
const { data: searchResponse, isLoading: isSearchLoading } = useQuery({ const { data: searchResponse, isLoading: isSearchLoading } = useQuery({
queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab), queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab),
@ -226,7 +204,7 @@ export function InboxSidebar({
limit: 50, limit: 50,
}, },
}), }),
staleTime: 30 * 1000, // 30 seconds (search results don't need to be super fresh) staleTime: 30 * 1000,
enabled: isSearchMode && open, enabled: isSearchMode && open,
}); });
@ -244,129 +222,128 @@ export function InboxSidebar({
return () => document.removeEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]); }, [open, onOpenChange]);
// Only lock body scroll on mobile when inbox is open
useEffect(() => { useEffect(() => {
if (!open || !isMobile) return; if (!open || !isMobile) return;
// Store original overflow to restore on cleanup
const originalOverflow = document.body.style.overflow; const originalOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
return () => { return () => {
document.body.style.overflow = originalOverflow; document.body.style.overflow = originalOverflow;
}; };
}, [open, isMobile]); }, [open, isMobile]);
// Reset connector filter when switching away from status tab
useEffect(() => { useEffect(() => {
if (activeTab !== "status") { if (activeTab !== "status") {
setSelectedConnector(null); setSelectedSource(null);
} }
}, [activeTab]); }, [activeTab]);
// Each tab uses its own data source for independent pagination // Active tab's data source — fully independent loading, pagination, and counts
// Comments tab: uses mentions data source (fetches only mention/reply types from server) const activeSource = activeTab === "comments" ? comments : status;
const commentsItems = mentions.items;
// Status tab: filters status data source (fetches all types) to status-specific types // Fetch source types for the status tab filter
const statusItems = useMemo( const { data: sourceTypesData } = useQuery({
() => queryKey: cacheKeys.notifications.sourceTypes(searchSpaceId),
status.items.filter( queryFn: () => notificationsApiService.getSourceTypes(searchSpaceId ?? undefined),
(item) => staleTime: 60 * 1000,
item.type === "connector_indexing" || enabled: open && activeTab === "status",
item.type === "document_processing" || });
item.type === "page_limit_exceeded" ||
item.type === "connector_deletion" const statusSourceOptions = useMemo(() => {
), if (!sourceTypesData?.sources) return [];
[status.items]
return sourceTypesData.sources.map((source) => ({
key: source.key,
type: source.type,
category: source.category,
displayName:
source.category === "connector"
? getConnectorTypeDisplayName(source.type)
: getDocumentTypeLabel(source.type),
}));
}, [sourceTypesData]);
// Client-side filter: source type
const matchesSourceFilter = useCallback(
(item: InboxItem): boolean => {
if (!selectedSource) return true;
if (selectedSource.startsWith("connector:")) {
const connectorType = selectedSource.slice("connector:".length);
return (
item.type === "connector_indexing" &&
isConnectorIndexingMetadata(item.metadata) &&
item.metadata.connector_type === connectorType
);
}
if (selectedSource.startsWith("doctype:")) {
const docType = selectedSource.slice("doctype:".length);
return (
item.type === "document_processing" &&
isDocumentProcessingMetadata(item.metadata) &&
item.metadata.document_type === docType
);
}
return true;
},
[selectedSource]
); );
// Pagination switches based on active tab // Client-side filter: unread / errors
const loading = activeTab === "comments" ? mentions.loading : status.loading; const matchesActiveFilter = useCallback(
const loadingMore = (item: InboxItem): boolean => {
activeTab === "comments" ? (mentions.loadingMore ?? false) : (status.loadingMore ?? false); if (activeFilter === "unread") return !item.read;
const hasMore = if (activeFilter === "errors") {
activeTab === "comments" ? (mentions.hasMore ?? false) : (status.hasMore ?? false); if (item.type === "page_limit_exceeded") return true;
const loadMore = activeTab === "comments" ? mentions.loadMore : status.loadMore; const meta = item.metadata as Record<string, unknown> | undefined;
return typeof meta?.status === "string" && meta.status === "failed";
}
return true;
},
[activeFilter]
);
// Get unique connector types from status items for filtering // Two data paths: search mode (API) or default (per-tab data source)
const uniqueConnectorTypes = useMemo(() => {
const connectorTypes = new Set<string>();
statusItems
.filter((item) => item.type === "connector_indexing")
.forEach((item) => {
// Use type guard for safe metadata access
if (isConnectorIndexingMetadata(item.metadata)) {
connectorTypes.add(item.metadata.connector_type);
}
});
return Array.from(connectorTypes).map((type) => ({
type,
displayName: getConnectorTypeDisplayName(type),
}));
}, [statusItems]);
// Get items for current tab
const displayItems = activeTab === "comments" ? commentsItems : statusItems;
// Filter items based on filter type, connector filter, and search mode
// When searching: use server-side API results (searches ALL notifications)
// When not searching: use Electric real-time items (fast, local)
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
// In search mode, use API results let tabItems: InboxItem[];
let items: InboxItem[] = isSearchMode ? (searchResponse?.items ?? []) : displayItems;
// For status tab search results, filter to status-specific types if (isSearchMode) {
if (isSearchMode && activeTab === "status") { tabItems = searchResponse?.items ?? [];
items = items.filter( } else {
(item) => tabItems = activeSource.items;
item.type === "connector_indexing" ||
item.type === "document_processing" ||
item.type === "page_limit_exceeded" ||
item.type === "connector_deletion"
);
} }
// Apply read/unread filter let result = tabItems;
if (activeFilter === "unread") { if (activeFilter !== "all") {
items = items.filter((item) => !item.read); result = result.filter(matchesActiveFilter);
}
if (activeTab === "status" && selectedSource) {
result = result.filter(matchesSourceFilter);
} }
// Apply connector filter (only for status tab) return result;
if (activeTab === "status" && selectedConnector) { }, [
items = items.filter((item) => { isSearchMode,
if (item.type === "connector_indexing") { searchResponse,
// Use type guard for safe metadata access activeSource.items,
if (isConnectorIndexingMetadata(item.metadata)) { activeTab,
return item.metadata.connector_type === selectedConnector; activeFilter,
} selectedSource,
return false; matchesActiveFilter,
} matchesSourceFilter,
return false; // Hide document_processing when a specific connector is selected ]);
});
}
return items; // Infinite scroll — uses active tab's pagination
}, [displayItems, searchResponse, isSearchMode, activeFilter, activeTab, selectedConnector]);
// Intersection Observer for infinite scroll with prefetching
// Re-runs when active tab changes so each tab gets its own pagination
// Disabled during server-side search (search results are not paginated via infinite scroll)
useEffect(() => { useEffect(() => {
if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return; if (!activeSource.hasMore || activeSource.loadingMore || !open || isSearchMode) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
// When trigger element is visible, load more
if (entries[0]?.isIntersecting) { if (entries[0]?.isIntersecting) {
loadMore(); activeSource.loadMore();
} }
}, },
{ {
root: null, // viewport root: null,
rootMargin: "100px", // Start loading 100px before visible rootMargin: "100px",
threshold: 0, threshold: 0,
} }
); );
@ -376,17 +353,13 @@ export function InboxSidebar({
} }
return () => observer.disconnect(); return () => observer.disconnect();
}, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]); }, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, open, isSearchMode]);
// Unread counts from server-side accurate totals (passed via props)
const unreadCommentsCount = mentions.unreadCount;
const unreadStatusCount = status.unreadCount;
const handleItemClick = useCallback( const handleItemClick = useCallback(
async (item: InboxItem) => { async (item: InboxItem) => {
if (!item.read) { if (!item.read) {
setMarkingAsReadId(item.id); setMarkingAsReadId(item.id);
await markAsRead(item.id); await activeSource.markAsRead(item.id);
setMarkingAsReadId(null); setMarkingAsReadId(null);
} }
@ -427,7 +400,6 @@ export function InboxSidebar({
} }
} }
} else if (item.type === "page_limit_exceeded") { } else if (item.type === "page_limit_exceeded") {
// Navigate to the upgrade/more-pages page
if (isPageLimitExceededMetadata(item.metadata)) { if (isPageLimitExceededMetadata(item.metadata)) {
const actionUrl = item.metadata.action_url; const actionUrl = item.metadata.action_url;
if (actionUrl) { if (actionUrl) {
@ -438,12 +410,12 @@ export function InboxSidebar({
} }
} }
}, },
[markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId] [activeSource.markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
); );
const handleMarkAllAsRead = useCallback(async () => { const handleMarkAllAsRead = useCallback(async () => {
await markAllAsRead(); await Promise.all([comments.markAllAsRead(), status.markAllAsRead()]);
}, [markAllAsRead]); }, [comments.markAllAsRead, status.markAllAsRead]);
const handleClearSearch = useCallback(() => { const handleClearSearch = useCallback(() => {
setSearchQuery(""); setSearchQuery("");
@ -469,7 +441,6 @@ export function InboxSidebar({
}; };
const getStatusIcon = (item: InboxItem) => { const getStatusIcon = (item: InboxItem) => {
// For mentions and comment replies, show the author's avatar
if (item.type === "new_mention" || item.type === "comment_reply") { if (item.type === "new_mention" || item.type === "comment_reply") {
const metadata = const metadata =
item.type === "new_mention" item.type === "new_mention"
@ -501,7 +472,6 @@ export function InboxSidebar({
); );
} }
// For page limit exceeded, show a warning icon with amber/orange color
if (item.type === "page_limit_exceeded") { if (item.type === "page_limit_exceeded") {
return ( return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10"> <div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10">
@ -510,8 +480,6 @@ export function InboxSidebar({
); );
} }
// For status items (connector/document), show status icons
// Safely access status from metadata
const metadata = item.metadata as Record<string, unknown>; const metadata = item.metadata as Record<string, unknown>;
const status = typeof metadata?.status === "string" ? metadata.status : undefined; const status = typeof metadata?.status === "string" ? metadata.status : undefined;
@ -558,13 +526,13 @@ export function InboxSidebar({
if (!mounted) return null; if (!mounted) return null;
// Shared content component for both docked and floating modes const isLoading = isSearchMode ? isSearchLoading : activeSource.loading;
const inboxContent = ( const inboxContent = (
<> <>
<div className="shrink-0 p-4 pb-2 space-y-3"> <div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Back button - mobile only */}
{isMobile && ( {isMobile && (
<Button <Button
variant="ghost" variant="ghost"
@ -579,7 +547,6 @@ export function InboxSidebar({
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2> <h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Mobile: Button that opens bottom drawer */}
{isMobile ? ( {isMobile ? (
<> <>
<Button <Button
@ -605,7 +572,6 @@ export function InboxSidebar({
</DrawerTitle> </DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4"> <div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Filter section */}
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground/80 font-medium px-1"> <p className="text-xs text-muted-foreground/80 font-medium px-1">
{t("filter") || "Filter"} {t("filter") || "Filter"}
@ -649,56 +615,74 @@ export function InboxSidebar({
</span> </span>
{activeFilter === "unread" && <Check className="h-4 w-4" />} {activeFilter === "unread" && <Check className="h-4 w-4" />}
</button> </button>
{activeTab === "status" && (
<button
type="button"
onClick={() => {
setActiveFilter("errors");
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
activeFilter === "errors"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
>
<span className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span>{t("errors_only") || "Errors only"}</span>
</span>
{activeFilter === "errors" && <Check className="h-4 w-4" />}
</button>
)}
</div> </div>
</div> </div>
{/* Connectors section - only for status tab */} {activeTab === "status" && statusSourceOptions.length > 0 && (
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground/80 font-medium px-1"> <p className="text-xs text-muted-foreground/80 font-medium px-1">
{t("connectors") || "Connectors"} {t("sources") || "Sources"}
</p> </p>
<div className="space-y-1"> <div className="space-y-1">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedConnector(null); setSelectedSource(null);
setFilterDrawerOpen(false); setFilterDrawerOpen(false);
}} }}
className={cn( className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
selectedConnector === null selectedSource === null
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "hover:bg-muted" : "hover:bg-muted"
)} )}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" /> <LayoutGrid className="h-4 w-4" />
<span>{t("all_connectors") || "All connectors"}</span> <span>{t("all_sources") || "All sources"}</span>
</span> </span>
{selectedConnector === null && <Check className="h-4 w-4" />} {selectedSource === null && <Check className="h-4 w-4" />}
</button> </button>
{uniqueConnectorTypes.map((connector) => ( {statusSourceOptions.map((source) => (
<button <button
key={connector.type} key={source.key}
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedConnector(connector.type); setSelectedSource(source.key);
setFilterDrawerOpen(false); setFilterDrawerOpen(false);
}} }}
className={cn( className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
selectedConnector === connector.type selectedSource === source.key
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "hover:bg-muted" : "hover:bg-muted"
)} )}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{getConnectorIcon(connector.type, "h-4 w-4")} {getConnectorIcon(source.type, "h-4 w-4")}
<span>{connector.displayName}</span> <span>{source.displayName}</span>
</span> </span>
{selectedConnector === connector.type && ( {selectedSource === source.key && <Check className="h-4 w-4" />}
<Check className="h-4 w-4" />
)}
</button> </button>
))} ))}
</div> </div>
@ -709,7 +693,6 @@ export function InboxSidebar({
</Drawer> </Drawer>
</> </>
) : ( ) : (
/* Desktop: Dropdown menu */
<DropdownMenu <DropdownMenu
open={openDropdown === "filter"} open={openDropdown === "filter"}
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)} onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
@ -727,7 +710,10 @@ export function InboxSidebar({
</Tooltip> </Tooltip>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
className={cn("z-80 select-none", activeTab === "status" ? "w-52" : "w-44")} className={cn(
"z-80 select-none max-h-[60vh] overflow-hidden flex flex-col",
activeTab === "status" ? "w-52" : "w-44"
)}
> >
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal"> <DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
{t("filter") || "Filter"} {t("filter") || "Filter"}
@ -752,13 +738,25 @@ export function InboxSidebar({
</span> </span>
{activeFilter === "unread" && <Check className="h-4 w-4" />} {activeFilter === "unread" && <Check className="h-4 w-4" />}
</DropdownMenuItem> </DropdownMenuItem>
{activeTab === "status" && uniqueConnectorTypes.length > 0 && ( {activeTab === "status" && (
<DropdownMenuItem
onClick={() => setActiveFilter("errors")}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span>{t("errors_only") || "Errors only"}</span>
</span>
{activeFilter === "errors" && <Check className="h-4 w-4" />}
</DropdownMenuItem>
)}
{activeTab === "status" && statusSourceOptions.length > 0 && (
<> <>
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2"> <DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
{t("connectors") || "Connectors"} {t("sources") || "Sources"}
</DropdownMenuLabel> </DropdownMenuLabel>
<div <div
className="relative max-h-[30vh] overflow-y-auto -mb-1" className="relative max-h-[30vh] overflow-y-auto overflow-x-hidden -mb-1"
onScroll={handleConnectorScroll} onScroll={handleConnectorScroll}
style={{ style={{
maskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`, maskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
@ -766,26 +764,26 @@ export function InboxSidebar({
}} }}
> >
<DropdownMenuItem <DropdownMenuItem
onClick={() => setSelectedConnector(null)} onClick={() => setSelectedSource(null)}
className="flex items-center justify-between" className="flex items-center justify-between"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" /> <LayoutGrid className="h-4 w-4" />
<span>{t("all_connectors") || "All connectors"}</span> <span>{t("all_sources") || "All sources"}</span>
</span> </span>
{selectedConnector === null && <Check className="h-4 w-4" />} {selectedSource === null && <Check className="h-4 w-4" />}
</DropdownMenuItem> </DropdownMenuItem>
{uniqueConnectorTypes.map((connector) => ( {statusSourceOptions.map((source) => (
<DropdownMenuItem <DropdownMenuItem
key={connector.type} key={source.key}
onClick={() => setSelectedConnector(connector.type)} onClick={() => setSelectedSource(source.key)}
className="flex items-center justify-between" className="flex items-center justify-between"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{getConnectorIcon(connector.type, "h-4 w-4")} {getConnectorIcon(source.type, "h-4 w-4")}
<span>{connector.displayName}</span> <span>{source.displayName}</span>
</span> </span>
{selectedConnector === connector.type && <Check className="h-4 w-4" />} {selectedSource === source.key && <Check className="h-4 w-4" />}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</div> </div>
@ -824,7 +822,6 @@ export function InboxSidebar({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
{/* Dock/Undock button - desktop only */}
{!isMobile && onDockedChange && ( {!isMobile && onDockedChange && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -834,12 +831,10 @@ export function InboxSidebar({
className="h-8 w-8 rounded-full" className="h-8 w-8 rounded-full"
onClick={() => { onClick={() => {
if (isDocked) { if (isDocked) {
// Collapse: show comments immediately, then close inbox
setCommentsCollapsed(false); setCommentsCollapsed(false);
onDockedChange(false); onDockedChange(false);
onOpenChange(false); onOpenChange(false);
} else { } else {
// Expand: hide comments immediately
setCommentsCollapsed(true); setCommentsCollapsed(true);
onDockedChange(true); onDockedChange(true);
} }
@ -886,7 +881,13 @@ export function InboxSidebar({
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={(value) => setActiveTab(value as InboxTab)} onValueChange={(value) => {
const tab = value as InboxTab;
setActiveTab(tab);
if (tab !== "status" && activeFilter === "errors") {
setActiveFilter("all");
}
}}
className="shrink-0 mx-4" className="shrink-0 mx-4"
> >
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b"> <TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
@ -898,7 +899,7 @@ export function InboxSidebar({
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
<span>{t("comments") || "Comments"}</span> <span>{t("comments") || "Comments"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium"> <span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{formatInboxCount(unreadCommentsCount)} {formatInboxCount(comments.unreadCount)}
</span> </span>
</span> </span>
</TabsTrigger> </TabsTrigger>
@ -910,7 +911,7 @@ export function InboxSidebar({
<History className="h-4 w-4" /> <History className="h-4 w-4" />
<span>{t("status") || "Status"}</span> <span>{t("status") || "Status"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium"> <span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{formatInboxCount(unreadStatusCount)} {formatInboxCount(status.unreadCount)}
</span> </span>
</span> </span>
</TabsTrigger> </TabsTrigger>
@ -918,11 +919,10 @@ export function InboxSidebar({
</Tabs> </Tabs>
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2"> <div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{(isSearchMode ? isSearchLoading : loading) ? ( {isLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{activeTab === "comments" {activeTab === "comments"
? /* Comments skeleton: avatar + two-line text + time */ ? [85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
[85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
<div <div
key={`skeleton-comment-${i}`} key={`skeleton-comment-${i}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]" className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
@ -935,8 +935,7 @@ export function InboxSidebar({
<Skeleton className="h-3 w-6 shrink-0 rounded" /> <Skeleton className="h-3 w-6 shrink-0 rounded" />
</div> </div>
)) ))
: /* Status skeleton: status icon circle + two-line text + time */ : [75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
<div <div
key={`skeleton-status-${i}`} key={`skeleton-status-${i}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]" className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
@ -957,9 +956,8 @@ export function InboxSidebar({
<div className="space-y-2"> <div className="space-y-2">
{filteredItems.map((item, index) => { {filteredItems.map((item, index) => {
const isMarkingAsRead = markingAsReadId === item.id; const isMarkingAsRead = markingAsReadId === item.id;
// Place prefetch trigger on 5th item from end (only when not searching)
const isPrefetchTrigger = const isPrefetchTrigger =
!isSearchMode && hasMore && index === filteredItems.length - 5; !isSearchMode && activeSource.hasMore && index === filteredItems.length - 5;
return ( return (
<div <div
@ -1028,7 +1026,6 @@ export function InboxSidebar({
</Tooltip> </Tooltip>
)} )}
{/* Time and unread dot - fixed width to prevent content shift */}
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10"> <div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
{formatTime(item.created_at)} {formatTime(item.created_at)}
@ -1038,12 +1035,10 @@ export function InboxSidebar({
</div> </div>
); );
})} })}
{/* Fallback trigger at the very end if less than 5 items and not searching */} {!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && (
{!isSearchMode && filteredItems.length < 5 && hasMore && (
<div ref={prefetchTriggerRef} className="h-1" /> <div ref={prefetchTriggerRef} className="h-1" />
)} )}
{/* Loading more skeletons at the bottom during infinite scroll */} {activeSource.loadingMore &&
{loadingMore &&
(activeTab === "comments" (activeTab === "comments"
? [80, 60, 90].map((titleWidth, i) => ( ? [80, 60, 90].map((titleWidth, i) => (
<div <div
@ -1100,11 +1095,10 @@ export function InboxSidebar({
</> </>
); );
// DOCKED MODE: Render as a static flex child (no animation, no click-away)
if (isDocked && open && !isMobile) { if (isDocked && open && !isMobile) {
return ( return (
<aside <aside
className="h-full w-[360px] shrink-0 bg-background flex flex-col border-r" className="h-full w-[360px] shrink-0 bg-sidebar text-sidebar-foreground flex flex-col border-r"
aria-label={t("inbox") || "Inbox"} aria-label={t("inbox") || "Inbox"}
> >
{inboxContent} {inboxContent}
@ -1112,7 +1106,6 @@ export function InboxSidebar({
); );
} }
// FLOATING MODE: Render with animation and click-away layer
return ( return (
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}> <SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
{inboxContent} {inboxContent}

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { NavItem } from "../../types/layout.types"; import type { NavItem } from "../../types/layout.types";
@ -39,11 +40,15 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
{...joyrideAttr} {...joyrideAttr}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{item.badge && ( {item.showSpinner ? (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-primary/15">
<Spinner size="xs" className="text-primary" />
</span>
) : item.badge ? (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full bg-red-500 text-white text-[9px] font-medium"> <span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full bg-red-500 text-white text-[9px] font-medium">
{item.badge} {item.badge}
</span> </span>
)} ) : null}
<span className="sr-only">{item.title}</span> <span className="sr-only">{item.title}</span>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@ -67,7 +72,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
)} )}
{...joyrideAttr} {...joyrideAttr}
> >
<Icon className="h-4 w-4 shrink-0" /> {item.showSpinner ? (
<Spinner size="sm" className="shrink-0 text-primary" />
) : (
<Icon className="h-4 w-4 shrink-0" />
)}
<span className="flex-1 truncate">{item.title}</span> <span className="flex-1 truncate">{item.title}</span>
{item.badge && ( {item.badge && (
<span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium"> <span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">

View file

@ -3,6 +3,7 @@
import { PanelLeft, PanelLeftClose } from "lucide-react"; import { PanelLeft, PanelLeftClose } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut"; import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
@ -18,7 +19,7 @@ export function SidebarCollapseButton({
disableTooltip = false, disableTooltip = false,
}: SidebarCollapseButtonProps) { }: SidebarCollapseButtonProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const { shortcut } = usePlatformShortcut(); const { shortcutKeys } = usePlatformShortcut();
const button = ( const button = (
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0"> <Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
@ -35,9 +36,10 @@ export function SidebarCollapseButton({
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger> <TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side={isCollapsed ? "right" : "bottom"}> <TooltipContent side={isCollapsed ? "right" : "bottom"}>
{isCollapsed <span className="flex items-center">
? `${t("expand_sidebar")} ${shortcut("Mod", "\\")}` {isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
: `${t("collapse_sidebar")} ${shortcut("Mod", "\\")}`} <ShortcutKbd keys={shortcutKeys("Mod", "\\")} />
</span>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );

View file

@ -8,7 +8,6 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -56,7 +55,6 @@ export function SidebarHeader({
<UserPen className="h-4 w-4" /> <UserPen className="h-4 w-4" />
{t("manage_members")} {t("manage_members")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSettings}> <DropdownMenuItem onClick={onSettings}>
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
{t("search_space_settings")} {t("search_space_settings")}

View file

@ -65,7 +65,7 @@ export function SidebarSlideOutPanel({
exit={{ x: "-100%" }} exit={{ x: "-100%" }}
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }} transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={cn( className={cn(
"h-full w-full bg-background flex flex-col pointer-events-auto select-none", "h-full w-full bg-sidebar text-sidebar-foreground flex flex-col pointer-events-auto select-none",
"sm:border-r sm:shadow-xl" "sm:border-r sm:shadow-xl"
)} )}
role="dialog" role="dialog"

View file

@ -188,7 +188,7 @@ export function SidebarUserProfile({
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator className="dark:bg-neutral-700" />
<DropdownMenuItem onClick={onUserSettings}> <DropdownMenuItem onClick={onUserSettings}>
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
@ -256,7 +256,7 @@ export function SidebarUserProfile({
</DropdownMenuPortal> </DropdownMenuPortal>
</DropdownMenuSub> </DropdownMenuSub>
<DropdownMenuSeparator /> <DropdownMenuSeparator className="dark:bg-neutral-700" />
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}> <DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
{isLoggingOut ? ( {isLoggingOut ? (
@ -310,7 +310,7 @@ export function SidebarUserProfile({
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator className="dark:bg-neutral-700" />
<DropdownMenuItem onClick={onUserSettings}> <DropdownMenuItem onClick={onUserSettings}>
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
@ -378,7 +378,7 @@ export function SidebarUserProfile({
</DropdownMenuPortal> </DropdownMenuPortal>
</DropdownMenuSub> </DropdownMenuSub>
<DropdownMenuSeparator /> <DropdownMenuSeparator className="dark:bg-neutral-700" />
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}> <DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
{isLoggingOut ? <Spinner size="sm" className="mr-2" /> : <LogOut className="h-4 w-4" />} {isLoggingOut ? <Spinner size="sm" className="mr-2" /> : <LogOut className="h-4 w-4" />}

View file

@ -2,6 +2,7 @@ export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar"; export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
export { AnnouncementsSidebar } from "./AnnouncementsSidebar"; export { AnnouncementsSidebar } from "./AnnouncementsSidebar";
export { ChatListItem } from "./ChatListItem"; export { ChatListItem } from "./ChatListItem";
export { DocumentsSidebar } from "./DocumentsSidebar";
export { InboxSidebar } from "./InboxSidebar"; export { InboxSidebar } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { NavSection } from "./NavSection"; export { NavSection } from "./NavSection";

View file

@ -7,38 +7,39 @@ import type {
ImageGenerationConfig, ImageGenerationConfig,
NewLLMConfigPublic, NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types"; } from "@/contracts/types/new-llm-config.types";
import { ImageConfigSidebar } from "./image-config-sidebar"; import { ImageConfigDialog } from "./image-config-dialog";
import { ModelConfigSidebar } from "./model-config-sidebar"; import { ModelConfigDialog } from "./model-config-dialog";
import { ModelSelector } from "./model-selector"; import { ModelSelector } from "./model-selector";
interface ChatHeaderProps { interface ChatHeaderProps {
searchSpaceId: number; searchSpaceId: number;
className?: string;
} }
export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
// LLM config sidebar state // LLM config dialog state
const [sidebarOpen, setSidebarOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [selectedConfig, setSelectedConfig] = useState< const [selectedConfig, setSelectedConfig] = useState<
NewLLMConfigPublic | GlobalNewLLMConfig | null NewLLMConfigPublic | GlobalNewLLMConfig | null
>(null); >(null);
const [isGlobal, setIsGlobal] = useState(false); const [isGlobal, setIsGlobal] = useState(false);
const [sidebarMode, setSidebarMode] = useState<"create" | "edit" | "view">("view"); const [dialogMode, setDialogMode] = useState<"create" | "edit" | "view">("view");
// Image config sidebar state // Image config dialog state
const [imageSidebarOpen, setImageSidebarOpen] = useState(false); const [imageDialogOpen, setImageDialogOpen] = useState(false);
const [selectedImageConfig, setSelectedImageConfig] = useState< const [selectedImageConfig, setSelectedImageConfig] = useState<
ImageGenerationConfig | GlobalImageGenConfig | null ImageGenerationConfig | GlobalImageGenConfig | null
>(null); >(null);
const [isImageGlobal, setIsImageGlobal] = useState(false); const [isImageGlobal, setIsImageGlobal] = useState(false);
const [imageSidebarMode, setImageSidebarMode] = useState<"create" | "edit" | "view">("view"); const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view");
// LLM handlers // LLM handlers
const handleEditLLMConfig = useCallback( const handleEditLLMConfig = useCallback(
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => { (config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
setSelectedConfig(config); setSelectedConfig(config);
setIsGlobal(global); setIsGlobal(global);
setSidebarMode(global ? "view" : "edit"); setDialogMode(global ? "view" : "edit");
setSidebarOpen(true); setDialogOpen(true);
}, },
[] []
); );
@ -46,12 +47,12 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
const handleAddNewLLM = useCallback(() => { const handleAddNewLLM = useCallback(() => {
setSelectedConfig(null); setSelectedConfig(null);
setIsGlobal(false); setIsGlobal(false);
setSidebarMode("create"); setDialogMode("create");
setSidebarOpen(true); setDialogOpen(true);
}, []); }, []);
const handleSidebarClose = useCallback((open: boolean) => { const handleDialogClose = useCallback((open: boolean) => {
setSidebarOpen(open); setDialogOpen(open);
if (!open) setSelectedConfig(null); if (!open) setSelectedConfig(null);
}, []); }, []);
@ -59,22 +60,22 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
const handleAddImageModel = useCallback(() => { const handleAddImageModel = useCallback(() => {
setSelectedImageConfig(null); setSelectedImageConfig(null);
setIsImageGlobal(false); setIsImageGlobal(false);
setImageSidebarMode("create"); setImageDialogMode("create");
setImageSidebarOpen(true); setImageDialogOpen(true);
}, []); }, []);
const handleEditImageConfig = useCallback( const handleEditImageConfig = useCallback(
(config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => { (config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => {
setSelectedImageConfig(config); setSelectedImageConfig(config);
setIsImageGlobal(global); setIsImageGlobal(global);
setImageSidebarMode(global ? "view" : "edit"); setImageDialogMode(global ? "view" : "edit");
setImageSidebarOpen(true); setImageDialogOpen(true);
}, },
[] []
); );
const handleImageSidebarClose = useCallback((open: boolean) => { const handleImageDialogClose = useCallback((open: boolean) => {
setImageSidebarOpen(open); setImageDialogOpen(open);
if (!open) setSelectedImageConfig(null); if (!open) setSelectedImageConfig(null);
}, []); }, []);
@ -85,22 +86,23 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
onAddNewLLM={handleAddNewLLM} onAddNewLLM={handleAddNewLLM}
onEditImage={handleEditImageConfig} onEditImage={handleEditImageConfig}
onAddNewImage={handleAddImageModel} onAddNewImage={handleAddImageModel}
className={className}
/> />
<ModelConfigSidebar <ModelConfigDialog
open={sidebarOpen} open={dialogOpen}
onOpenChange={handleSidebarClose} onOpenChange={handleDialogClose}
config={selectedConfig} config={selectedConfig}
isGlobal={isGlobal} isGlobal={isGlobal}
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
mode={sidebarMode} mode={dialogMode}
/> />
<ImageConfigSidebar <ImageConfigDialog
open={imageSidebarOpen} open={imageDialogOpen}
onOpenChange={handleImageSidebarClose} onOpenChange={handleImageDialogClose}
config={selectedImageConfig} config={selectedImageConfig}
isGlobal={isImageGlobal} isGlobal={isImageGlobal}
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
mode={imageSidebarMode} mode={imageDialogMode}
/> />
</div> </div>
); );

View file

@ -72,12 +72,15 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
// Query to check if thread has public snapshots // Query to check if thread has public snapshots
const { data: snapshotsData } = useQuery({ const { data: snapshotsData } = useQuery({
queryKey: ["thread-snapshots", thread?.id], queryKey: ["thread-snapshots", thread?.id],
queryFn: () => chatThreadsApiService.listPublicChatSnapshots({ thread_id: thread!.id }), queryFn: () => {
const id = thread?.id;
if (id == null) throw new Error("Missing thread id");
return chatThreadsApiService.listPublicChatSnapshots({ thread_id: id });
},
enabled: !!thread?.id, enabled: !!thread?.id,
staleTime: 30000, // Cache for 30 seconds staleTime: 30000, // Cache for 30 seconds
}); });
const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0; const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0;
const snapshotCount = snapshotsData?.snapshots?.length ?? 0;
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE"; const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
@ -152,11 +155,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
<Earth className="h-4 w-4 text-muted-foreground" /> <Earth className="h-4 w-4 text-muted-foreground" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>Manage public links</TooltipContent>
{snapshotCount === 1
? "This chat has a public link"
: `This chat has ${snapshotCount} public links`}
</TooltipContent>
</Tooltip> </Tooltip>
)} )}
@ -167,7 +166,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0" className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0 select-none"
> >
<CurrentIcon className="h-4 w-4" /> <CurrentIcon className="h-4 w-4" />
<span className="hidden md:inline text-sm">{buttonLabel}</span> <span className="hidden md:inline text-sm">{buttonLabel}</span>
@ -178,12 +177,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
</Tooltip> </Tooltip>
<PopoverContent <PopoverContent
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60" className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
align="end" align="end"
sideOffset={8} sideOffset={8}
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
> >
<div className="p-1.5 space-y-1 select-none"> <div className="p-1.5 space-y-1">
{/* Visibility Options */} {/* Visibility Options */}
{visibilityOptions.map((option) => { {visibilityOptions.map((option) => {
const isSelected = currentVisibility === option.value; const isSelected = currentVisibility === option.value;
@ -196,27 +195,32 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
onClick={() => handleVisibilityChange(option.value)} onClick={() => handleVisibilityChange(option.value)}
className={cn( className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all", "w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer", "hover:bg-accent/50 dark:hover:bg-white/10 cursor-pointer",
"focus:outline-none", "focus:outline-none",
isSelected && "bg-accent/80" isSelected && "bg-accent/80 dark:bg-white/10"
)} )}
> >
<div <div
className={cn( className={cn(
"size-7 rounded-md shrink-0 grid place-items-center", "size-7 rounded-md shrink-0 grid place-items-center",
isSelected ? "bg-primary/10" : "bg-muted" isSelected ? "bg-primary/10 dark:bg-white/10" : "bg-muted dark:bg-white/5"
)} )}
> >
<Icon <Icon
className={cn( className={cn(
"size-4 block", "size-4 block",
isSelected ? "text-primary" : "text-muted-foreground" isSelected ? "text-primary dark:text-white" : "text-muted-foreground"
)} )}
/> />
</div> </div>
<div className="flex-1 text-left min-w-0"> <div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className={cn("text-sm font-medium", isSelected && "text-primary")}> <span
className={cn(
"text-sm font-medium",
isSelected && "text-primary dark:text-white"
)}
>
{option.label} {option.label}
</span> </span>
</div> </div>
@ -231,7 +235,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
{canCreatePublicLink && ( {canCreatePublicLink && (
<> <>
{/* Divider */} {/* Divider */}
<div className="border-t border-border my-1" /> <div className="border-t border-border dark:border-white/5 my-1" />
{/* Public Link Option */} {/* Public Link Option */}
<button <button
@ -240,12 +244,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
disabled={isCreatingSnapshot} disabled={isCreatingSnapshot}
className={cn( className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all", "w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer", "hover:bg-accent/50 dark:hover:bg-white/10 cursor-pointer",
"focus:outline-none", "focus:outline-none",
"disabled:opacity-50 disabled:cursor-not-allowed" "disabled:opacity-50 disabled:cursor-not-allowed"
)} )}
> >
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted"> <div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted dark:bg-white/5">
<Earth className="size-4 block text-muted-foreground" /> <Earth className="size-4 block text-muted-foreground" />
</div> </div>
<div className="flex-1 text-left min-w-0"> <div className="flex-1 text-left min-w-0">

View file

@ -1,19 +1,9 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import { AlertCircle, Check, ChevronsUpDown, X } from "lucide-react";
AlertCircle,
Check,
ChevronsUpDown,
Globe,
ImageIcon,
Key,
Shuffle,
X,
Zap,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -48,10 +38,11 @@ import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-g
import type { import type {
GlobalImageGenConfig, GlobalImageGenConfig,
ImageGenerationConfig, ImageGenerationConfig,
ImageGenProvider,
} from "@/contracts/types/new-llm-config.types"; } from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface ImageConfigSidebarProps { interface ImageConfigDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
config: ImageGenerationConfig | GlobalImageGenConfig | null; config: ImageGenerationConfig | GlobalImageGenConfig | null;
@ -70,24 +61,25 @@ const INITIAL_FORM = {
api_version: "", api_version: "",
}; };
export function ImageConfigSidebar({ export function ImageConfigDialog({
open, open,
onOpenChange, onOpenChange,
config, config,
isGlobal, isGlobal,
searchSpaceId, searchSpaceId,
mode, mode,
}: ImageConfigSidebarProps) { }: ImageConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [formData, setFormData] = useState(INITIAL_FORM); const [formData, setFormData] = useState(INITIAL_FORM);
const [modelComboboxOpen, setModelComboboxOpen] = useState(false); const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
// Reset form when opening
useEffect(() => { useEffect(() => {
if (open) { if (open) {
if (mode === "edit" && config && !isGlobal) { if (mode === "edit" && config && !isGlobal) {
@ -103,15 +95,14 @@ export function ImageConfigSidebar({
} else if (mode === "create") { } else if (mode === "create") {
setFormData(INITIAL_FORM); setFormData(INITIAL_FORM);
} }
setScrollPos("top");
} }
}, [open, mode, config, isGlobal]); }, [open, mode, config, isGlobal]);
// Mutations
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom); const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom); const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
// Escape key
useEffect(() => { useEffect(() => {
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) onOpenChange(false); if (e.key === "Escape" && open) onOpenChange(false);
@ -120,6 +111,13 @@ export function ImageConfigSidebar({
return () => window.removeEventListener("keydown", handleEscape); return () => window.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]); }, [open, onOpenChange]);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode; const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
const suggestedModels = useMemo(() => { const suggestedModels = useMemo(() => {
@ -134,13 +132,20 @@ export function ImageConfigSidebar({
return "Edit Image Model"; return "Edit Image Model";
}; };
const getSubtitle = () => {
if (mode === "create") return "Set up a new image generation provider";
if (isAutoMode) return "Automatically routes requests across providers";
if (isGlobal) return "Read-only global configuration";
return "Update your image model settings";
};
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
if (mode === "create") { if (mode === "create") {
const result = await createConfig({ const result = await createConfig({
name: formData.name, name: formData.name,
provider: formData.provider, provider: formData.provider as ImageGenProvider,
model_name: formData.model_name, model_name: formData.model_name,
api_key: formData.api_key, api_key: formData.api_key,
api_base: formData.api_base || undefined, api_base: formData.api_base || undefined,
@ -148,7 +153,6 @@ export function ImageConfigSidebar({
description: formData.description || undefined, description: formData.description || undefined,
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
}); });
// Set as active image model
if (result?.id) { if (result?.id) {
await updatePreferences({ await updatePreferences({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
@ -163,7 +167,7 @@ export function ImageConfigSidebar({
data: { data: {
name: formData.name, name: formData.name,
description: formData.description || undefined, description: formData.description || undefined,
provider: formData.provider, provider: formData.provider as ImageGenProvider,
model_name: formData.model_name, model_name: formData.model_name,
api_key: formData.api_key, api_key: formData.api_key,
api_base: formData.api_base || undefined, api_base: formData.api_base || undefined,
@ -214,126 +218,96 @@ export function ImageConfigSidebar({
if (!mounted) return null; if (!mounted) return null;
const sidebarContent = ( const dialogContent = (
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<> <>
{/* Backdrop */}
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.15 }}
className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm" className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
/> />
{/* Sidebar */}
<motion.div <motion.div
initial={{ x: "100%", opacity: 0 }} initial={{ opacity: 0, scale: 0.96 }}
animate={{ x: 0, opacity: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ x: "100%", opacity: 0 }} exit={{ opacity: 0, scale: 0.96 }}
transition={{ type: "spring", damping: 30, stiffness: 300 }} transition={{ duration: 0.15, ease: "easeOut" }}
className={cn( className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
"fixed right-0 top-0 z-50 h-full w-full sm:w-[480px] lg:w-[540px]",
"bg-background border-l border-border/50 shadow-2xl",
"flex flex-col"
)}
> >
{/* Header */}
<div <div
role="dialog"
aria-modal="true"
className={cn( className={cn(
"flex items-center justify-between px-6 py-4 border-b border-border/50", "relative w-full max-w-lg h-[85vh]",
isAutoMode "rounded-xl bg-background shadow-2xl ring-1 ring-border/50",
? "bg-gradient-to-r from-violet-500/10 to-purple-500/10" "dark:bg-neutral-900 dark:ring-white/5",
: "bg-gradient-to-r from-teal-500/10 to-cyan-500/10" "flex flex-col overflow-hidden"
)} )}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape") onOpenChange(false);
}}
> >
<div className="flex items-center gap-3"> {/* Header */}
<div <div className="flex items-start justify-between px-6 pt-6 pb-4">
className={cn( <div className="space-y-1 pr-8">
"flex items-center justify-center size-10 rounded-xl", <div className="flex items-center gap-2">
isAutoMode <h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
? "bg-gradient-to-br from-violet-500 to-purple-600" {isAutoMode && (
: "bg-gradient-to-br from-teal-500 to-cyan-600" <Badge variant="secondary" className="text-[10px]">
)}
>
{isAutoMode ? (
<Shuffle className="size-5 text-white" />
) : (
<ImageIcon className="size-5 text-white" />
)}
</div>
<div>
<h2 className="text-base sm:text-lg font-semibold">{getTitle()}</h2>
<div className="flex items-center gap-2 mt-0.5">
{isAutoMode ? (
<Badge
variant="secondary"
className="gap-1 text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
>
<Zap className="size-3" />
Recommended Recommended
</Badge> </Badge>
) : isGlobal ? ( )}
<Badge variant="secondary" className="gap-1 text-xs"> {isGlobal && !isAutoMode && mode !== "create" && (
<Globe className="size-3" /> <Badge variant="secondary" className="text-[10px]">
Global Global
</Badge> </Badge>
) : null}
{config && !isAutoMode && (
<span className="text-xs text-muted-foreground">{config.model_name}</span>
)} )}
</div> </div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && !isAutoMode && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70">
{config.model_name}
</p>
)}
</div> </div>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div> </div>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8 rounded-full"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
{/* Content */} {/* Scrollable content */}
<div className="flex-1 overflow-y-auto"> <div
<div className="p-6"> ref={scrollRef}
{/* Auto mode */} onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{isAutoMode && ( {isAutoMode && (
<> <Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5"> <AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
<Shuffle className="size-4 text-violet-500" /> Auto mode distributes image generation requests across all configured
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400"> providers for optimal performance and rate limit protection.
Auto mode distributes image generation requests across all configured </AlertDescription>
providers for optimal performance and rate limit protection. </Alert>
</AlertDescription>
</Alert>
<div className="flex gap-3 pt-4 border-t border-border/50">
<Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Close
</Button>
<Button
className="flex-1 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use Auto Mode"}
</Button>
</div>
</>
)} )}
{/* Global config (read-only) */}
{isGlobal && !isAutoMode && config && ( {isGlobal && !isAutoMode && config && (
<> <>
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5"> <Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
<AlertCircle className="size-4 text-amber-500" /> <AlertCircle className="size-4 text-amber-500" />
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400"> <AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
Global configurations are read-only. To customize, create a new model. Global configurations are read-only. To customize, create a new model.
@ -372,29 +346,11 @@ export function ImageConfigSidebar({
</div> </div>
</div> </div>
</div> </div>
<div className="flex gap-3 pt-6 border-t border-border/50 mt-6">
<Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Close
</Button>
<Button
className="flex-1 gap-2"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use This Model"}
</Button>
</div>
</> </>
)} )}
{/* Create / Edit form */}
{(mode === "create" || (mode === "edit" && !isGlobal)) && ( {(mode === "create" || (mode === "edit" && !isGlobal)) && (
<div className="space-y-4"> <div className="space-y-4">
{/* Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Name *</Label> <Label className="text-sm font-medium">Name *</Label>
<Input <Input
@ -404,7 +360,6 @@ export function ImageConfigSidebar({
/> />
</div> </div>
{/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Description</Label> <Label className="text-sm font-medium">Description</Label>
<Input <Input
@ -418,7 +373,6 @@ export function ImageConfigSidebar({
<Separator /> <Separator />
{/* Provider */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Provider *</Label> <Label className="text-sm font-medium">Provider *</Label>
<Select <Select
@ -430,20 +384,16 @@ export function ImageConfigSidebar({
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a provider" /> <SelectValue placeholder="Select a provider" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="bg-muted dark:border-neutral-700">
{IMAGE_GEN_PROVIDERS.map((p) => ( {IMAGE_GEN_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value}> <SelectItem key={p.value} value={p.value} description={p.example}>
<div className="flex flex-col"> {p.label}
<span className="font-medium">{p.label}</span>
<span className="text-xs text-muted-foreground">{p.example}</span>
</div>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Model Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Model Name *</Label> <Label className="text-sm font-medium">Model Name *</Label>
{suggestedModels.length > 0 ? ( {suggestedModels.length > 0 ? (
@ -452,14 +402,17 @@ export function ImageConfigSidebar({
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
className="w-full justify-between font-normal" className="w-full justify-between font-normal bg-transparent"
> >
{formData.model_name || "Select or type a model..."} {formData.model_name || "Select or type a model..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-full p-0" align="start"> <PopoverContent
<Command> className="w-full p-0 bg-muted dark:border-neutral-700"
align="start"
>
<Command className="bg-transparent">
<CommandInput <CommandInput
placeholder="Search or type model..." placeholder="Search or type model..."
value={formData.model_name} value={formData.model_name}
@ -513,11 +466,8 @@ export function ImageConfigSidebar({
)} )}
</div> </div>
{/* API Key */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium flex items-center gap-1.5"> <Label className="text-sm font-medium">API Key *</Label>
<Key className="h-3.5 w-3.5" /> API Key *
</Label>
<Input <Input
type="password" type="password"
placeholder="sk-..." placeholder="sk-..."
@ -526,7 +476,6 @@ export function ImageConfigSidebar({
/> />
</div> </div>
{/* API Base */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">API Base URL</Label> <Label className="text-sm font-medium">API Base URL</Label>
<Input <Input
@ -536,7 +485,6 @@ export function ImageConfigSidebar({
/> />
</div> </div>
{/* Azure API Version */}
{formData.provider === "AZURE_OPENAI" && ( {formData.provider === "AZURE_OPENAI" && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">API Version (Azure)</Label> <Label className="text-sm font-medium">API Version (Azure)</Label>
@ -549,28 +497,56 @@ export function ImageConfigSidebar({
/> />
</div> </div>
)} )}
{/* Actions */}
<div className="flex gap-3 pt-4 border-t">
<Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
className="flex-1 gap-2 bg-gradient-to-r from-teal-500 to-cyan-600 hover:from-teal-600 hover:to-cyan-700"
onClick={handleSubmit}
disabled={isSubmitting || !isFormValid}
>
{isSubmitting ? <Spinner size="sm" className="mr-2" /> : null}
{mode === "edit" ? "Save Changes" : "Create & Use"}
</Button>
</div>
</div> </div>
)} )}
</div> </div>
{/* Fixed footer */}
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
Cancel
</Button>
{mode === "create" || (mode === "edit" && !isGlobal) ? (
<Button
onClick={handleSubmit}
disabled={isSubmitting || !isFormValid}
className="text-sm h-9 min-w-[120px]"
>
{isSubmitting ? (
<>
<Spinner size="sm" />
{mode === "edit" ? "Saving" : "Creating"}
</>
) : mode === "edit" ? (
"Save Changes"
) : (
"Create & Use"
)}
</Button>
) : isAutoMode ? (
<Button
className="text-sm h-9 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use Auto Mode"}
</Button>
) : isGlobal && config ? (
<Button
className="text-sm h-9 gap-2"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use This Model"}
</Button>
) : null}
</div>
</div> </div>
</motion.div> </motion.div>
</> </>
@ -578,5 +554,5 @@ export function ImageConfigSidebar({
</AnimatePresence> </AnimatePresence>
); );
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null; return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null;
} }

View file

@ -1,9 +1,9 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { AlertCircle, Bot, ChevronRight, Globe, Shuffle, User, X, Zap } from "lucide-react"; import { AlertCircle, X, Zap } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -15,13 +15,15 @@ import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-c
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { import type {
GlobalNewLLMConfig, GlobalNewLLMConfig,
LiteLLMProvider,
NewLLMConfigPublic, NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types"; } from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface ModelConfigSidebarProps { interface ModelConfigDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
config: NewLLMConfigPublic | GlobalNewLLMConfig | null; config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
@ -30,28 +32,34 @@ interface ModelConfigSidebarProps {
mode: "create" | "edit" | "view"; mode: "create" | "edit" | "view";
} }
export function ModelConfigSidebar({ export function ModelConfigDialog({
open, open,
onOpenChange, onOpenChange,
config, config,
isGlobal, isGlobal,
searchSpaceId, searchSpaceId,
mode, mode,
}: ModelConfigSidebarProps) { }: ModelConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
// Handle SSR - only render portal on client
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
// Mutations - use mutateAsync from the atom value const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom); const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom); const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
// Handle escape key
useEffect(() => { useEffect(() => {
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) { if (e.key === "Escape" && open) {
@ -62,10 +70,8 @@ export function ModelConfigSidebar({
return () => window.removeEventListener("keydown", handleEscape); return () => window.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]); }, [open, onOpenChange]);
// Check if this is Auto mode
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode; const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
// Get title based on mode
const getTitle = () => { const getTitle = () => {
if (mode === "create") return "Add New Configuration"; if (mode === "create") return "Add New Configuration";
if (isAutoMode) return "Auto Mode (Fastest)"; if (isAutoMode) return "Auto Mode (Fastest)";
@ -73,19 +79,23 @@ export function ModelConfigSidebar({
return "Edit Configuration"; return "Edit Configuration";
}; };
// Handle form submit const getSubtitle = () => {
if (mode === "create") return "Set up a new LLM provider for this search space";
if (isAutoMode) return "Automatically routes requests across providers";
if (isGlobal) return "Read-only global configuration";
return "Update your configuration settings";
};
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (data: LLMConfigFormData) => { async (data: LLMConfigFormData) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
if (mode === "create") { if (mode === "create") {
// Create new config
const result = await createConfig({ const result = await createConfig({
...data, ...data,
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
}); });
// Assign the new config to the agent role
if (result?.id) { if (result?.id) {
await updatePreferences({ await updatePreferences({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
@ -98,7 +108,6 @@ export function ModelConfigSidebar({
toast.success("Configuration created and assigned!"); toast.success("Configuration created and assigned!");
onOpenChange(false); onOpenChange(false);
} else if (!isGlobal && config) { } else if (!isGlobal && config) {
// Update existing user config
await updateConfig({ await updateConfig({
id: config.id, id: config.id,
data: { data: {
@ -137,7 +146,6 @@ export function ModelConfigSidebar({
] ]
); );
// Handle "Use this model" for global configs
const handleUseGlobalConfig = useCallback(async () => { const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return; if (!config || !isGlobal) return;
setIsSubmitting(true); setIsSubmitting(true);
@ -160,7 +168,7 @@ export function ModelConfigSidebar({
if (!mounted) return null; if (!mounted) return null;
const sidebarContent = ( const dialogContent = (
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<> <>
@ -169,93 +177,84 @@ export function ModelConfigSidebar({
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.15 }}
className="fixed inset-0 z-[24] bg-black/20 backdrop-blur-sm" className="fixed inset-0 z-[24] bg-black/50 backdrop-blur-sm"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
/> />
{/* Sidebar Panel */} {/* Dialog */}
<motion.div <motion.div
initial={{ x: "100%", opacity: 0 }} initial={{ opacity: 0, scale: 0.96 }}
animate={{ x: 0, opacity: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ x: "100%", opacity: 0 }} exit={{ opacity: 0, scale: 0.96 }}
transition={{ transition={{ duration: 0.15, ease: "easeOut" }}
type: "spring", className="fixed inset-0 z-[25] flex items-center justify-center p-4 sm:p-6"
damping: 30,
stiffness: 300,
}}
className={cn(
"fixed right-0 top-0 z-[25] h-full w-full sm:w-[480px] lg:w-[540px]",
"bg-background border-l border-border/50 shadow-2xl",
"flex flex-col"
)}
> >
{/* Header */}
<div <div
role="dialog"
aria-modal="true"
className={cn( className={cn(
"flex items-center justify-between px-6 py-4 border-b border-border/50", "relative w-full max-w-lg h-[85vh]",
isAutoMode ? "bg-gradient-to-r from-violet-500/10 to-purple-500/10" : "bg-muted/20" "rounded-xl bg-background shadow-2xl ring-1 ring-border/50",
"dark:bg-neutral-900 dark:ring-white/5",
"flex flex-col overflow-hidden"
)} )}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape") onOpenChange(false);
}}
> >
<div className="flex items-center gap-3"> {/* Header */}
<div <div className="flex items-start justify-between px-6 pt-6 pb-4">
className={cn( <div className="space-y-1 pr-8">
"flex items-center justify-center size-10 rounded-xl", <div className="flex items-center gap-2">
isAutoMode ? "bg-gradient-to-br from-violet-500 to-purple-600" : "bg-primary/10" <h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
)} {isAutoMode && (
> <Badge variant="secondary" className="text-[10px]">
{isAutoMode ? (
<Shuffle className="size-5 text-white" />
) : (
<Bot className="size-5 text-primary" />
)}
</div>
<div>
<h2 className="text-base sm:text-lg font-semibold">{getTitle()}</h2>
<div className="flex items-center gap-2 mt-0.5">
{isAutoMode ? (
<Badge
variant="secondary"
className="gap-1 text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
>
<Zap className="size-3" />
Recommended Recommended
</Badge> </Badge>
) : isGlobal ? ( )}
<Badge variant="secondary" className="gap-1 text-xs"> {isGlobal && !isAutoMode && mode !== "create" && (
<Globe className="size-3" /> <Badge variant="secondary" className="text-[10px]">
Global Global
</Badge> </Badge>
) : mode !== "create" ? ( )}
<Badge variant="outline" className="gap-1 text-xs"> {!isGlobal && mode !== "create" && !isAutoMode && (
<User className="size-3" /> <Badge variant="outline" className="text-[10px]">
Custom Custom
</Badge> </Badge>
) : null}
{config && !isAutoMode && (
<span className="text-xs text-muted-foreground">{config.model_name}</span>
)} )}
</div> </div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && !isAutoMode && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70">
{config.model_name}
</p>
)}
</div> </div>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div> </div>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8 rounded-full"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
{/* Content - use overflow-y-auto instead of ScrollArea for better compatibility */} {/* Scrollable content */}
<div className="flex-1 overflow-y-auto"> <div
<div className="p-6"> ref={scrollRef}
{/* Auto mode info banner */} onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{isAutoMode && ( {isAutoMode && (
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5"> <Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
<Shuffle className="size-4 text-violet-500" />
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400"> <AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
Auto mode automatically distributes requests across all available LLM Auto mode automatically distributes requests across all available LLM
providers to optimize performance and avoid rate limits. providers to optimize performance and avoid rate limits.
@ -263,9 +262,8 @@ export function ModelConfigSidebar({
</Alert> </Alert>
)} )}
{/* Global config notice */}
{isGlobal && !isAutoMode && mode !== "create" && ( {isGlobal && !isAutoMode && mode !== "create" && (
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5"> <Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
<AlertCircle className="size-4 text-amber-500" /> <AlertCircle className="size-4 text-amber-500" />
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400"> <AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
Global configurations are read-only. To customize settings, create a new Global configurations are read-only. To customize settings, create a new
@ -274,20 +272,17 @@ export function ModelConfigSidebar({
</Alert> </Alert>
)} )}
{/* Form */}
{mode === "create" ? ( {mode === "create" ? (
<LLMConfigForm <LLMConfigForm
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
mode="create" mode="create"
submitLabel="Create & Use" formId="model-config-form"
hideActions
/> />
) : isAutoMode && config ? ( ) : isAutoMode && config ? (
// Special view for Auto mode
<div className="space-y-6"> <div className="space-y-6">
{/* Auto Mode Features */}
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
@ -339,36 +334,9 @@ export function ModelConfigSidebar({
</div> </div>
</div> </div>
</div> </div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t border-border/50">
<Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Close
</Button>
<Button
className="flex-1 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? (
<>Loading...</>
) : (
<>
<ChevronRight className="size-4" />
Use Auto Mode
</>
)}
</Button>
</div>
</div> </div>
) : isGlobal && config ? ( ) : isGlobal && config ? (
// Read-only view for global configs
<div className="space-y-6"> <div className="space-y-6">
{/* Config Details */}
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -436,43 +404,17 @@ export function ModelConfigSidebar({
</> </>
)} )}
</div> </div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t border-border/50">
<Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
>
Close
</Button>
<Button
className="flex-1 gap-2"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? (
<>Loading...</>
) : (
<>
<ChevronRight className="size-4" />
Use This Model
</>
)}
</Button>
</div>
</div> </div>
) : config ? ( ) : config ? (
// Edit form for user configs
<LLMConfigForm <LLMConfigForm
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
initialData={{ initialData={{
name: config.name, name: config.name,
description: config.description, description: config.description,
provider: config.provider, provider: config.provider as LiteLLMProvider,
custom_provider: config.custom_provider, custom_provider: config.custom_provider,
model_name: config.model_name, model_name: config.model_name,
api_key: config.api_key, api_key: "api_key" in config ? (config.api_key as string) : "",
api_base: config.api_base, api_base: config.api_base,
litellm_params: config.litellm_params, litellm_params: config.litellm_params,
system_instructions: config.system_instructions, system_instructions: config.system_instructions,
@ -481,13 +423,61 @@ export function ModelConfigSidebar({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
}} }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
mode="edit" mode="edit"
submitLabel="Save Changes" formId="model-config-form"
hideActions
/> />
) : null} ) : null}
</div> </div>
{/* Fixed footer */}
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
Cancel
</Button>
{mode === "create" || (!isGlobal && !isAutoMode && config) ? (
<Button
type="submit"
form="model-config-form"
disabled={isSubmitting}
className="text-sm h-9 min-w-[120px]"
>
{isSubmitting ? (
<>
<Spinner size="sm" />
{mode === "edit" ? "Saving" : "Creating"}
</>
) : mode === "edit" ? (
"Save Changes"
) : (
"Create & Use"
)}
</Button>
) : isAutoMode ? (
<Button
className="text-sm h-9 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use Auto Mode"}
</Button>
) : isGlobal && config ? (
<Button
className="text-sm h-9 gap-2"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use This Model"}
</Button>
) : null}
</div>
</div> </div>
</motion.div> </motion.div>
</> </>
@ -495,5 +485,5 @@ export function ModelConfigSidebar({
</AnimatePresence> </AnimatePresence>
); );
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null; return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null;
} }

View file

@ -2,7 +2,7 @@
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Zap } from "lucide-react"; import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Zap } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { type UIEvent, useCallback, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
globalImageGenConfigsAtom, globalImageGenConfigsAtom,
@ -57,6 +57,17 @@ export function ModelSelector({
const [activeTab, setActiveTab] = useState<"llm" | "image">("llm"); const [activeTab, setActiveTab] = useState<"llm" | "image">("llm");
const [llmSearchQuery, setLlmSearchQuery] = useState(""); const [llmSearchQuery, setLlmSearchQuery] = useState("");
const [imageSearchQuery, setImageSearchQuery] = useState(""); const [imageSearchQuery, setImageSearchQuery] = useState("");
const [llmScrollPos, setLlmScrollPos] = useState<"top" | "middle" | "bottom">("top");
const [imageScrollPos, setImageScrollPos] = useState<"top" | "middle" | "bottom">("top");
const handleListScroll = useCallback(
(setter: typeof setLlmScrollPos) => (e: UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setter(atTop ? "top" : atBottom ? "bottom" : "middle");
},
[]
);
// LLM data // LLM data
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom); const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
@ -253,7 +264,7 @@ export function ModelSelector({
)} )}
{/* Divider */} {/* Divider */}
<div className="h-4 w-px bg-border/60 mx-0.5" /> <div className="h-4 w-px bg-border/60 dark:bg-white/10 mx-0.5" />
{/* Image section */} {/* Image section */}
{currentImageConfig ? ( {currentImageConfig ? (
@ -280,7 +291,7 @@ export function ModelSelector({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-muted dark:border dark:border-neutral-700 select-none" className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
align="start" align="start"
sideOffset={8} sideOffset={8}
> >
@ -289,7 +300,7 @@ export function ModelSelector({
onValueChange={(v) => setActiveTab(v as "llm" | "image")} onValueChange={(v) => setActiveTab(v as "llm" | "image")}
className="w-full" className="w-full"
> >
<div className="border-b border-border/80 dark:border-white/5"> <div className="border-b border-border/80 dark:border-neutral-800">
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0"> <TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
<TabsTrigger <TabsTrigger
value="llm" value="llm"
@ -312,7 +323,7 @@ export function ModelSelector({
<TabsContent value="llm" className="mt-0"> <TabsContent value="llm" className="mt-0">
<Command <Command
shouldFilter={false} shouldFilter={false}
className="rounded-none rounded-b-lg relative dark:bg-muted [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2" className="rounded-none rounded-b-lg relative dark:bg-neutral-900 [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
> >
{totalLLMModels > 3 && ( {totalLLMModels > 3 && (
<div className="px-2 md:px-3 py-1.5 md:py-2"> <div className="px-2 md:px-3 py-1.5 md:py-2">
@ -325,7 +336,14 @@ export function ModelSelector({
</div> </div>
)} )}
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto"> <CommandList
className="max-h-[300px] md:max-h-[400px] overflow-y-auto"
onScroll={handleListScroll(setLlmScrollPos)}
style={{
maskImage: `linear-gradient(to bottom, ${llmScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${llmScrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${llmScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${llmScrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
<CommandEmpty className="py-8 text-center"> <CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Bot className="size-8 text-muted-foreground" /> <Bot className="size-8 text-muted-foreground" />
@ -350,8 +368,8 @@ export function ModelSelector({
onSelect={() => handleSelectLLM(config)} onSelect={() => handleSelectLLM(config)}
className={cn( className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all", "mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
"hover:bg-accent/50 dark:hover:bg-white/10", "hover:bg-accent/50 dark:hover:bg-white/[0.06]",
isSelected && "bg-accent/80 dark:bg-white/10", isSelected && "bg-accent/80 dark:bg-white/[0.06]",
isAutoMode && "" isAutoMode && ""
)} )}
> >
@ -426,8 +444,8 @@ export function ModelSelector({
onSelect={() => handleSelectLLM(config)} onSelect={() => handleSelectLLM(config)}
className={cn( className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all", "mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
"hover:bg-accent/50 dark:hover:bg-white/10", "hover:bg-accent/50 dark:hover:bg-white/[0.06]",
isSelected && "bg-accent/80 dark:bg-white/10" isSelected && "bg-accent/80 dark:bg-white/[0.06]"
)} )}
> >
<div className="flex items-center justify-between w-full gap-2"> <div className="flex items-center justify-between w-full gap-2">
@ -471,11 +489,11 @@ export function ModelSelector({
)} )}
{/* Add New LLM Config */} {/* Add New LLM Config */}
<div className="p-2 bg-muted/20 dark:bg-muted"> <div className="p-2 bg-muted/20 dark:bg-neutral-900">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/10" className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/[0.06]"
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);
onAddNewLLM(); onAddNewLLM();
@ -493,7 +511,7 @@ export function ModelSelector({
<TabsContent value="image" className="mt-0"> <TabsContent value="image" className="mt-0">
<Command <Command
shouldFilter={false} shouldFilter={false}
className="rounded-none rounded-b-lg dark:bg-muted [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2" className="rounded-none rounded-b-lg dark:bg-neutral-900 [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
> >
{totalImageModels > 3 && ( {totalImageModels > 3 && (
<div className="px-2 md:px-3 py-1.5 md:py-2"> <div className="px-2 md:px-3 py-1.5 md:py-2">
@ -505,7 +523,14 @@ export function ModelSelector({
/> />
</div> </div>
)} )}
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto"> <CommandList
className="max-h-[300px] md:max-h-[400px] overflow-y-auto"
onScroll={handleListScroll(setImageScrollPos)}
style={{
maskImage: `linear-gradient(to bottom, ${imageScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${imageScrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${imageScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${imageScrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
<CommandEmpty className="py-8 text-center"> <CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<ImageIcon className="size-8 text-muted-foreground" /> <ImageIcon className="size-8 text-muted-foreground" />
@ -528,8 +553,8 @@ export function ModelSelector({
value={`img-g-${config.id}`} value={`img-g-${config.id}`}
onSelect={() => handleSelectImage(config.id)} onSelect={() => handleSelectImage(config.id)}
className={cn( className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10", "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
isSelected && "bg-accent/80 dark:bg-white/10", isSelected && "bg-accent/80 dark:bg-white/[0.06]",
isAuto && "" isAuto && ""
)} )}
> >
@ -593,8 +618,8 @@ export function ModelSelector({
value={`img-u-${config.id}`} value={`img-u-${config.id}`}
onSelect={() => handleSelectImage(config.id)} onSelect={() => handleSelectImage(config.id)}
className={cn( className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10", "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
isSelected && "bg-accent/80 dark:bg-white/10" isSelected && "bg-accent/80 dark:bg-white/[0.06]"
)} )}
> >
<div className="flex items-center gap-3 min-w-0 flex-1"> <div className="flex items-center gap-3 min-w-0 flex-1">
@ -634,11 +659,11 @@ export function ModelSelector({
{/* Add New Image Config */} {/* Add New Image Config */}
{onAddNewImage && ( {onAddNewImage && (
<div className="p-2 bg-muted/20 dark:bg-muted"> <div className="p-2 bg-muted/20 dark:bg-neutral-900">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/10" className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/[0.06]"
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);
onAddNewImage(); onAddNewImage();

View file

@ -334,7 +334,7 @@ function ReportPanelContent({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
className={`min-w-[180px] bg-muted dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`} className={`min-w-[180px] dark:bg-neutral-900 dark:border dark:border-white/5${insideDrawer ? " z-[100]" : ""}`}
> >
<DropdownMenuItem onClick={() => handleExport("md")}> <DropdownMenuItem onClick={() => handleExport("md")}>
Download Markdown Download Markdown
@ -371,7 +371,7 @@ function ReportPanelContent({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
className={`min-w-[120px] bg-muted dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`} className={`min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5${insideDrawer ? " z-[100]" : ""}`}
> >
{versions.map((v, i) => ( {versions.map((v, i) => (
<DropdownMenuItem <DropdownMenuItem

View file

@ -578,10 +578,7 @@ function RolesContent({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<DropdownMenuItem <DropdownMenuItem onSelect={(e) => e.preventDefault()}>
className="text-destructive focus:text-destructive"
onSelect={(e) => e.preventDefault()}
>
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Delete Role Delete Role
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -2,16 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
Bot,
Check,
ChevronDown,
ChevronsUpDown,
Key,
MessageSquareQuote,
Rocket,
Sparkles,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -88,6 +79,8 @@ interface LLMConfigFormProps {
submitLabel?: string; submitLabel?: string;
showAdvanced?: boolean; showAdvanced?: boolean;
compact?: boolean; compact?: boolean;
formId?: string;
hideActions?: boolean;
} }
export function LLMConfigForm({ export function LLMConfigForm({
@ -100,6 +93,8 @@ export function LLMConfigForm({
submitLabel, submitLabel,
showAdvanced = true, showAdvanced = true,
compact = false, compact = false,
formId,
hideActions = false,
}: LLMConfigFormProps) { }: LLMConfigFormProps) {
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue( const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
defaultSystemInstructionsAtom defaultSystemInstructionsAtom
@ -164,11 +159,10 @@ export function LLMConfigForm({
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6"> <form id={formId} onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Model Configuration Section */} {/* Model Configuration Section */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground"> <div className="text-xs sm:text-sm font-medium text-muted-foreground">
<Bot className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
Model Configuration Model Configuration
</div> </div>
@ -179,16 +173,9 @@ export function LLMConfigForm({
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm"> <FormLabel className="text-xs sm:text-sm">Configuration Name</FormLabel>
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
Configuration Name
</FormLabel>
<FormControl> <FormControl>
<Input <Input placeholder="e.g., My GPT-4 Agent" {...field} />
placeholder="e.g., My GPT-4 Agent"
className="transition-all focus-visible:ring-violet-500/50"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -224,19 +211,18 @@ export function LLMConfigForm({
<FormLabel className="text-xs sm:text-sm">LLM Provider</FormLabel> <FormLabel className="text-xs sm:text-sm">LLM Provider</FormLabel>
<Select value={field.value} onValueChange={handleProviderChange}> <Select value={field.value} onValueChange={handleProviderChange}>
<FormControl> <FormControl>
<SelectTrigger className="transition-all focus:ring-violet-500/50"> <SelectTrigger>
<SelectValue placeholder="Select a provider" /> <SelectValue placeholder="Select a provider" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent className="max-h-[300px]"> <SelectContent className="max-h-[300px] bg-muted dark:border-neutral-700">
{LLM_PROVIDERS.map((provider) => ( {LLM_PROVIDERS.map((provider) => (
<SelectItem key={provider.value} value={provider.value}> <SelectItem
<div className="flex flex-col py-0.5"> key={provider.value}
<span className="font-medium">{provider.label}</span> value={provider.value}
<span className="text-xs text-muted-foreground"> description={provider.description}
{provider.description} >
</span> {provider.label}
</div>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -290,7 +276,7 @@ export function LLMConfigForm({
role="combobox" role="combobox"
aria-expanded={modelComboboxOpen} aria-expanded={modelComboboxOpen}
className={cn( className={cn(
"w-full justify-between font-normal", "w-full justify-between font-normal bg-transparent",
!field.value && "text-muted-foreground" !field.value && "text-muted-foreground"
)} )}
> >
@ -299,8 +285,11 @@ export function LLMConfigForm({
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-full p-0" align="start"> <PopoverContent
<Command shouldFilter={false}> className="w-full p-0 bg-muted dark:border-neutral-700"
align="start"
>
<Command shouldFilter={false} className="bg-transparent">
<CommandInput <CommandInput
placeholder={selectedProvider?.example || "Type model name..."} placeholder={selectedProvider?.example || "Type model name..."}
value={field.value} value={field.value}
@ -371,10 +360,7 @@ export function LLMConfigForm({
name="api_key" name="api_key"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm"> <FormLabel className="text-xs sm:text-sm">API Key</FormLabel>
<Key className="h-3.5 w-3.5 text-amber-500" />
API Key
</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="password" type="password"
@ -460,10 +446,7 @@ export function LLMConfigForm({
type="button" type="button"
className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors" className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
> >
<div className="flex items-center gap-2"> <span>Advanced Parameters</span>
<Sparkles className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
Advanced Parameters
</div>
<ChevronDown <ChevronDown
className={cn( className={cn(
"h-4 w-4 transition-transform duration-200", "h-4 w-4 transition-transform duration-200",
@ -501,10 +484,7 @@ export function LLMConfigForm({
type="button" type="button"
className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors" className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
> >
<div className="flex items-center gap-2"> <span>System Instructions</span>
<MessageSquareQuote className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
System Instructions
</div>
<ChevronDown <ChevronDown
className={cn( className={cn(
"h-4 w-4 transition-transform duration-200", "h-4 w-4 transition-transform duration-200",
@ -575,42 +555,43 @@ export function LLMConfigForm({
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
{/* Action Buttons */} {!hideActions && (
<div <div
className={cn( className={cn(
"flex gap-3 pt-4", "flex gap-3 pt-4",
compact ? "justify-end" : "justify-center sm:justify-end" compact ? "justify-end" : "justify-center sm:justify-end"
)}
>
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
className="text-xs sm:text-sm h-9 sm:h-10"
>
Cancel
</Button>
)}
<Button
type="submit"
disabled={isSubmitting}
className="gap-2 min-w-[140px] sm:min-w-[160px] text-xs sm:text-sm h-9 sm:h-10"
>
{isSubmitting ? (
<>
<Spinner size="sm" />
{mode === "edit" ? "Updating..." : "Creating"}
</>
) : (
<>
{!compact && <Rocket className="h-3.5 w-3.5 sm:h-4 sm:w-4" />}
{submitLabel ?? (mode === "edit" ? "Update Configuration" : "Create Configuration")}
</>
)} )}
</Button> >
</div> {onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
className="text-xs sm:text-sm h-9 sm:h-10"
>
Cancel
</Button>
)}
<Button
type="submit"
disabled={isSubmitting}
className="gap-2 min-w-[140px] sm:min-w-[160px] text-xs sm:text-sm h-9 sm:h-10"
>
{isSubmitting ? (
<>
<Spinner size="sm" />
{mode === "edit" ? "Updating..." : "Creating"}
</>
) : (
<>
{submitLabel ??
(mode === "edit" ? "Update Configuration" : "Create Configuration")}
</>
)}
</Button>
</div>
)}
</form> </form>
</Form> </Form>
); );

View file

@ -2,7 +2,7 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react"; import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useDropzone } from "react-dropzone"; import { useDropzone } from "react-dropzone";
@ -241,12 +241,7 @@ export function DocumentUploadTab({
}; };
return ( return (
<motion.div <div className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0">
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0"
>
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0"> <Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0">
<Info className="h-4 w-4 shrink-0 mt-0.5" /> <Info className="h-4 w-4 shrink-0 mt-0.5" />
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5"> <AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
@ -287,14 +282,10 @@ export function DocumentUploadTab({
</div> </div>
</div> </div>
) : isDragActive ? ( ) : isDragActive ? (
<motion.div <div className="flex flex-col items-center gap-2 sm:gap-4">
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-2 sm:gap-4"
>
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" /> <Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" />
<p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p> <p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p>
</motion.div> </div>
) : ( ) : (
<div className="flex flex-col items-center gap-2 sm:gap-4"> <div className="flex flex-col items-center gap-2 sm:gap-4">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" /> <Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" />
@ -329,124 +320,102 @@ export function DocumentUploadTab({
</CardContent> </CardContent>
</Card> </Card>
<AnimatePresence mode="wait"> {files.length > 0 && (
{files.length > 0 && ( <Card className={cardClass}>
<motion.div <CardHeader className="p-4 sm:p-6">
initial={{ opacity: 0, height: 0 }} <div className="flex items-center justify-between gap-2">
animate={{ opacity: 1, height: "auto" }} <div className="min-w-0 flex-1">
exit={{ opacity: 0, height: 0 }} <CardTitle className="text-base sm:text-2xl">
transition={{ duration: 0.3 }} {t("selected_files", { count: files.length })}
> </CardTitle>
<Card className={cardClass}> <CardDescription className="text-xs sm:text-sm">
<CardHeader className="p-4 sm:p-6"> {t("total_size")}: {formatFileSize(totalFileSize)}
<div className="flex items-center justify-between gap-2"> </CardDescription>
<div className="min-w-0 flex-1"> </div>
<CardTitle className="text-base sm:text-2xl"> <Button
{t("selected_files", { count: files.length })} variant="outline"
</CardTitle> size="sm"
<CardDescription className="text-xs sm:text-sm"> className="text-xs sm:text-sm shrink-0"
{t("total_size")}: {formatFileSize(totalFileSize)} onClick={() => setFiles([])}
</CardDescription> disabled={isUploading}
>
{t("clear_all")}
</Button>
</div>
</CardHeader>
<CardContent className="p-4 sm:p-6 pt-0">
<div className="space-y-2 sm:space-y-3 max-h-[250px] sm:max-h-[400px] overflow-y-auto">
{files.map((file, index) => (
<div
key={`${file.name}-${index}`}
className={`flex items-center justify-between p-2 sm:p-4 rounded-lg border border-border ${cardClass} hover:bg-slate-400/10 dark:hover:bg-white/10 transition-colors`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium truncate">{file.name}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{formatFileSize(file.size)}
</Badge>
<Badge variant="outline" className="text-xs">
{file.type || "Unknown type"}
</Badge>
</div>
</div>
</div> </div>
<Button <Button
variant="outline" variant="ghost"
size="sm" size="icon"
className="text-xs sm:text-sm shrink-0" onClick={() => setFiles((prev) => prev.filter((_, i) => i !== index))}
onClick={() => setFiles([])}
disabled={isUploading} disabled={isUploading}
className="h-8 w-8"
> >
{t("clear_all")} <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
</CardHeader> ))}
<CardContent className="p-4 sm:p-6 pt-0"> </div>
<div className="space-y-2 sm:space-y-3 max-h-[250px] sm:max-h-[400px] overflow-y-auto">
<AnimatePresence>
{files.map((file, index) => (
<motion.div
key={`${file.name}-${index}`}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className={`flex items-center justify-between p-2 sm:p-4 rounded-lg border border-border ${cardClass} hover:bg-slate-400/10 dark:hover:bg-white/10 transition-colors`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium truncate">{file.name}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{formatFileSize(file.size)}
</Badge>
<Badge variant="outline" className="text-xs">
{file.type || "Unknown type"}
</Badge>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setFiles((prev) => prev.filter((_, i) => i !== index))}
disabled={isUploading}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</motion.div>
))}
</AnimatePresence>
</div>
{isUploading && ( {isUploading && (
<motion.div <div className="mt-3 sm:mt-6 space-y-2 sm:space-y-3">
initial={{ opacity: 0, y: 10 }} <Separator className="bg-border" />
animate={{ opacity: 1, y: 0 }} <div className="space-y-2">
className="mt-3 sm:mt-6 space-y-2 sm:space-y-3" <div className="flex items-center justify-between text-xs sm:text-sm">
> <span>{t("uploading_files")}</span>
<Separator className="bg-border" /> <span>{Math.round(uploadProgress)}%</span>
<div className="space-y-2"> </div>
<div className="flex items-center justify-between text-xs sm:text-sm"> <Progress value={uploadProgress} className="h-2" />
<span>{t("uploading_files")}</span> </div>
<span>{Math.round(uploadProgress)}%</span> </div>
</div> )}
<Progress value={uploadProgress} className="h-2" />
</div> <div className="mt-3 sm:mt-6">
</motion.div> <SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} />
</div>
<div className="mt-3 sm:mt-6">
<Button
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium"
onClick={handleUpload}
disabled={isUploading || files.length === 0}
>
{isUploading ? (
<span className="flex items-center gap-2">
<Spinner size="sm" />
{t("uploading")}
</span>
) : (
<span className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
{t("upload_button", { count: files.length })}
</span>
)} )}
</Button>
<div className="mt-3 sm:mt-6"> </div>
<SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} /> </CardContent>
</div> </Card>
)}
<motion.div
className="mt-3 sm:mt-6"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
<Button
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium"
onClick={handleUpload}
disabled={isUploading || files.length === 0}
>
{isUploading ? (
<span className="flex items-center gap-2">
<Spinner size="sm" />
{t("uploading")}
</span>
) : (
<span className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
{t("upload_button", { count: files.length })}
</span>
)}
</Button>
</motion.div>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
<Accordion <Accordion
type="single" type="single"
@ -479,6 +448,6 @@ export function DocumentUploadTab({
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
</motion.div> </div>
); );
} }

View file

@ -27,7 +27,7 @@ function AlertDialogOverlay({
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-sm",
className className
)} )}
{...props} {...props}
@ -45,7 +45,7 @@ function AlertDialogContent({
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background dark:bg-neutral-900 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl ring-1 ring-border/50 dark:ring-white/5 p-6 shadow-2xl duration-200 sm:max-w-lg",
className className
)} )}
{...props} {...props}
@ -113,7 +113,7 @@ function AlertDialogCancel({
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return ( return (
<AlertDialogPrimitive.Cancel <AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)} className={cn(buttonVariants({ variant: "secondary" }), className)}
{...props} {...props}
/> />
); );

View file

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

View file

@ -88,11 +88,14 @@ function Calendar({
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", "relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day defaultClassNames.day
), ),
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start), range_start: cn(
"rounded-l-md bg-accent dark:bg-neutral-700",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle), range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), range_end: cn("rounded-r-md bg-accent dark:bg-neutral-700", defaultClassNames.range_end),
today: cn( today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", "bg-accent dark:bg-neutral-700 text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today defaultClassNames.today
), ),
outside: cn( outside: cn(
@ -164,7 +167,7 @@ function CalendarDayButton({
data-range-end={modifiers.range_end} data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle} data-range-middle={modifiers.range_middle}
className={cn( className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70", "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent dark:data-[range-middle=true]:bg-neutral-700 data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:bg-neutral-700 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day, defaultClassNames.day,
className className
)} )}

View file

@ -66,7 +66,7 @@ function ContextMenuSubContent({
<ContextMenuPrimitive.SubContent <ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content" data-slot="context-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg", "bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-lg",
className className
)} )}
{...props} {...props}
@ -83,7 +83,7 @@ function ContextMenuContent({
<ContextMenuPrimitive.Content <ContextMenuPrimitive.Content
data-slot="context-menu-content" data-slot="context-menu-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md", "bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-md",
className className
)} )}
{...props} {...props}
@ -107,8 +107,7 @@ function ContextMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", "focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive",
className className
)} )}
{...props} {...props}
@ -190,7 +189,7 @@ function ContextMenuSeparator({
return ( return (
<ContextMenuPrimitive.Separator <ContextMenuPrimitive.Separator
data-slot="context-menu-separator" data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn("bg-border dark:bg-neutral-700 -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
); );

View file

@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 ring-1 ring-border/50 dark:ring-white/5 bg-background dark:bg-neutral-900 p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-xl focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 z-50 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <DialogPrimitive.Close className="absolute right-4 top-4 z-50 h-8 w-8 rounded-full inline-flex items-center justify-center text-muted-foreground transition-colors hover:text-foreground hover:bg-accent focus:outline-none disabled:pointer-events-none">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>

View file

@ -33,7 +33,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border dark:border-neutral-700 p-1 shadow-md",
className className
)} )}
{...props} {...props}
@ -61,7 +61,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
@ -149,7 +149,7 @@ function DropdownMenuSeparator({
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn("bg-border dark:bg-neutral-700 -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
); );
@ -201,7 +201,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", "bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-lg",
className className
)} )}
{...props} {...props}

View file

@ -13,9 +13,9 @@ import {
} from "lucide-react"; } from "lucide-react";
import { KEYS } from "platejs"; import { KEYS } from "platejs";
import { useEditorReadOnly, useEditorRef } from "platejs/react"; import { useEditorReadOnly, useEditorRef } from "platejs/react";
import * as React from "react";
import { useEditorSave } from "@/components/editor/editor-save-context"; import { useEditorSave } from "@/components/editor/editor-save-context";
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut"; import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
@ -26,11 +26,20 @@ import { ModeToolbarButton } from "./mode-toolbar-button";
import { ToolbarButton, ToolbarGroup } from "./toolbar"; import { ToolbarButton, ToolbarGroup } from "./toolbar";
import { TurnIntoToolbarButton } from "./turn-into-toolbar-button"; import { TurnIntoToolbarButton } from "./turn-into-toolbar-button";
function TooltipWithShortcut({ label, keys }: { label: string; keys: string[] }) {
return (
<span className="flex items-center">
{label}
<ShortcutKbd keys={keys} />
</span>
);
}
export function FixedToolbarButtons() { export function FixedToolbarButtons() {
const readOnly = useEditorReadOnly(); const readOnly = useEditorReadOnly();
const editor = useEditorRef(); const editor = useEditorRef();
const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave(); const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave();
const { shortcut } = usePlatformShortcut(); const { shortcutKeys } = usePlatformShortcut();
return ( return (
<div className="flex w-full items-center"> <div className="flex w-full items-center">
@ -40,7 +49,7 @@ export function FixedToolbarButtons() {
<> <>
<ToolbarGroup> <ToolbarGroup>
<ToolbarButton <ToolbarButton
tooltip={`Undo ${shortcut("Mod", "Z")}`} tooltip={<TooltipWithShortcut label="Undo" keys={shortcutKeys("Mod", "Z")} />}
onClick={() => { onClick={() => {
editor.undo(); editor.undo();
editor.tf.focus(); editor.tf.focus();
@ -50,7 +59,9 @@ export function FixedToolbarButtons() {
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
tooltip={`Redo ${shortcut("Mod", "Shift", "Z")}`} tooltip={
<TooltipWithShortcut label="Redo" keys={shortcutKeys("Mod", "Shift", "Z")} />
}
onClick={() => { onClick={() => {
editor.redo(); editor.redo();
editor.tf.focus(); editor.tf.focus();
@ -66,35 +77,51 @@ export function FixedToolbarButtons() {
</ToolbarGroup> </ToolbarGroup>
<ToolbarGroup> <ToolbarGroup>
<MarkToolbarButton nodeType={KEYS.bold} tooltip={`Bold ${shortcut("Mod", "B")}`}> <MarkToolbarButton
nodeType={KEYS.bold}
tooltip={<TooltipWithShortcut label="Bold" keys={shortcutKeys("Mod", "B")} />}
>
<BoldIcon /> <BoldIcon />
</MarkToolbarButton> </MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.italic} tooltip={`Italic ${shortcut("Mod", "I")}`}> <MarkToolbarButton
nodeType={KEYS.italic}
tooltip={<TooltipWithShortcut label="Italic" keys={shortcutKeys("Mod", "I")} />}
>
<ItalicIcon /> <ItalicIcon />
</MarkToolbarButton> </MarkToolbarButton>
<MarkToolbarButton <MarkToolbarButton
nodeType={KEYS.underline} nodeType={KEYS.underline}
tooltip={`Underline ${shortcut("Mod", "U")}`} tooltip={<TooltipWithShortcut label="Underline" keys={shortcutKeys("Mod", "U")} />}
> >
<UnderlineIcon /> <UnderlineIcon />
</MarkToolbarButton> </MarkToolbarButton>
<MarkToolbarButton <MarkToolbarButton
nodeType={KEYS.strikethrough} nodeType={KEYS.strikethrough}
tooltip={`Strikethrough ${shortcut("Mod", "Shift", "X")}`} tooltip={
<TooltipWithShortcut
label="Strikethrough"
keys={shortcutKeys("Mod", "Shift", "X")}
/>
}
> >
<StrikethroughIcon /> <StrikethroughIcon />
</MarkToolbarButton> </MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.code} tooltip={`Code ${shortcut("Mod", "E")}`}> <MarkToolbarButton
nodeType={KEYS.code}
tooltip={<TooltipWithShortcut label="Code" keys={shortcutKeys("Mod", "E")} />}
>
<Code2Icon /> <Code2Icon />
</MarkToolbarButton> </MarkToolbarButton>
<MarkToolbarButton <MarkToolbarButton
nodeType={KEYS.highlight} nodeType={KEYS.highlight}
tooltip={`Highlight ${shortcut("Mod", "Shift", "H")}`} tooltip={
<TooltipWithShortcut label="Highlight" keys={shortcutKeys("Mod", "Shift", "H")} />
}
> >
<HighlighterIcon /> <HighlighterIcon />
</MarkToolbarButton> </MarkToolbarButton>
@ -113,7 +140,13 @@ export function FixedToolbarButtons() {
{!readOnly && onSave && hasUnsavedChanges && ( {!readOnly && onSave && hasUnsavedChanges && (
<ToolbarGroup> <ToolbarGroup>
<ToolbarButton <ToolbarButton
tooltip={isSaving ? "Saving..." : `Save ${shortcut("Mod", "S")}`} tooltip={
isSaving ? (
"Saving..."
) : (
<TooltipWithShortcut label="Save" keys={shortcutKeys("Mod", "S")} />
)
}
onClick={onSave} onClick={onSave}
disabled={isSaving} disabled={isSaving}
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"

View file

@ -26,7 +26,7 @@ function PopoverContent({
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-hidden", "bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border dark:border-neutral-700 p-4 shadow-md outline-hidden",
className className
)} )}
{...props} {...props}

View file

@ -51,7 +51,7 @@ function SelectContent({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md", "bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border dark:border-neutral-700 shadow-md",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className
@ -88,18 +88,28 @@ function SelectLabel({ className, ...props }: React.ComponentProps<typeof Select
function SelectItem({ function SelectItem({
className, className,
children, children,
description,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) { }: React.ComponentProps<typeof SelectPrimitive.Item> & {
description?: string;
}) {
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent/50 focus:text-accent-foreground hover:bg-accent/50 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm outline-hidden select-none transition-all data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 data-[highlighted]:bg-accent/50", "focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 hover:bg-accent dark:hover:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm outline-hidden select-none transition-all data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 data-[highlighted]:bg-accent dark:data-[highlighted]:bg-neutral-700",
className className
)} )}
{...props} {...props}
> >
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> {description ? (
<div className="flex flex-col py-0.5">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<span className="text-xs text-muted-foreground">{description}</span>
</div>
) : (
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
)}
</SelectPrimitive.Item> </SelectPrimitive.Item>
); );
} }
@ -111,7 +121,7 @@ function SelectSeparator({
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
data-slot="select-separator" data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} className={cn("bg-border dark:bg-neutral-700 pointer-events-none -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
); );

View 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>
);
}

View file

@ -44,7 +44,7 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none", "bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none select-none",
className className
)} )}
{...props} {...props}

View file

@ -92,11 +92,16 @@ export const surfsenseDocsDocumentWithChunks = surfsenseDocsDocument.extend({
/** /**
* Get documents * Get documents
*/ */
export const documentSortByEnum = z.enum(["created_at", "title", "document_type"]);
export const sortOrderEnum = z.enum(["asc", "desc"]);
export const getDocumentsRequest = z.object({ export const getDocumentsRequest = z.object({
queryParams: paginationQueryParams queryParams: paginationQueryParams
.extend({ .extend({
search_space_id: z.number().or(z.string()).optional(), search_space_id: z.number().or(z.string()).optional(),
document_types: z.array(documentTypeEnum).optional(), document_types: z.array(documentTypeEnum).optional(),
sort_by: documentSortByEnum.optional(),
sort_order: sortOrderEnum.optional(),
}) })
.nullish(), .nullish(),
}); });
@ -311,6 +316,8 @@ export type UpdateDocumentResponse = z.infer<typeof updateDocumentResponse>;
export type DeleteDocumentRequest = z.infer<typeof deleteDocumentRequest>; export type DeleteDocumentRequest = z.infer<typeof deleteDocumentRequest>;
export type DeleteDocumentResponse = z.infer<typeof deleteDocumentResponse>; export type DeleteDocumentResponse = z.infer<typeof deleteDocumentResponse>;
export type DocumentTypeEnum = z.infer<typeof documentTypeEnum>; export type DocumentTypeEnum = z.infer<typeof documentTypeEnum>;
export type DocumentSortBy = z.infer<typeof documentSortByEnum>;
export type SortOrder = z.infer<typeof sortOrderEnum>;
export type SurfsenseDocsChunk = z.infer<typeof surfsenseDocsChunk>; export type SurfsenseDocsChunk = z.infer<typeof surfsenseDocsChunk>;
export type SurfsenseDocsDocument = z.infer<typeof surfsenseDocsDocument>; export type SurfsenseDocsDocument = z.infer<typeof surfsenseDocsDocument>;
export type SurfsenseDocsDocumentWithChunks = z.infer<typeof surfsenseDocsDocumentWithChunks>; export type SurfsenseDocsDocumentWithChunks = z.infer<typeof surfsenseDocsDocumentWithChunks>;

View file

@ -197,6 +197,12 @@ export const pageLimitExceededInboxItem = inboxItem.extend({
// API Request/Response Schemas // API Request/Response Schemas
// ============================================================================= // =============================================================================
/**
* Notification category for tab-level filtering
*/
export const notificationCategory = z.enum(["comments", "status"]);
export type NotificationCategory = z.infer<typeof notificationCategory>;
/** /**
* Request schema for getting notifications * Request schema for getting notifications
*/ */
@ -204,6 +210,9 @@ export const getNotificationsRequest = z.object({
queryParams: z.object({ queryParams: z.object({
search_space_id: z.number().optional(), search_space_id: z.number().optional(),
type: inboxItemTypeEnum.optional(), type: inboxItemTypeEnum.optional(),
category: notificationCategory.optional(),
source_type: z.string().optional(),
filter: z.enum(["unread", "errors"]).optional(),
before_date: z.string().optional(), before_date: z.string().optional(),
search: z.string().optional(), search: z.string().optional(),
limit: z.number().min(1).max(100).optional(), limit: z.number().min(1).max(100).optional(),
@ -261,6 +270,20 @@ export const getUnreadCountResponse = z.object({
recent_unread: z.number(), // Within SYNC_WINDOW_DAYS (14 days) recent_unread: z.number(), // Within SYNC_WINDOW_DAYS (14 days)
}); });
/**
* Response schema for notification source types (status tab filter)
*/
export const sourceTypeItem = z.object({
key: z.string(),
type: z.string(),
category: z.enum(["connector", "document"]),
count: z.number(),
});
export const getSourceTypesResponse = z.object({
sources: z.array(sourceTypeItem),
});
// ============================================================================= // =============================================================================
// Type Guards for Metadata // Type Guards for Metadata
// ============================================================================= // =============================================================================
@ -387,3 +410,5 @@ export type MarkNotificationReadResponse = z.infer<typeof markNotificationReadRe
export type MarkAllNotificationsReadResponse = z.infer<typeof markAllNotificationsReadResponse>; export type MarkAllNotificationsReadResponse = z.infer<typeof markAllNotificationsReadResponse>;
export type GetUnreadCountRequest = z.infer<typeof getUnreadCountRequest>; export type GetUnreadCountRequest = z.infer<typeof getUnreadCountRequest>;
export type GetUnreadCountResponse = z.infer<typeof getUnreadCountResponse>; export type GetUnreadCountResponse = z.infer<typeof getUnreadCountResponse>;
export type SourceTypeItem = z.infer<typeof sourceTypeItem>;
export type GetSourceTypesResponse = z.infer<typeof getSourceTypesResponse>;

View file

@ -13,88 +13,74 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const replacements = [ const replacements = [
[ [
"__NEXT_PUBLIC_FASTAPI_BACKEND_URL__", "__NEXT_PUBLIC_FASTAPI_BACKEND_URL__",
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000", process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000",
], ],
[ ["__NEXT_PUBLIC_ELECTRIC_URL__", process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133"],
"__NEXT_PUBLIC_ELECTRIC_URL__", [
process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133", "__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__",
], process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "LOCAL",
[ ],
"__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__", ["__NEXT_PUBLIC_ETL_SERVICE__", process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING"],
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "LOCAL", ["__NEXT_PUBLIC_DEPLOYMENT_MODE__", process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted"],
], ["__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__", process.env.NEXT_PUBLIC_ELECTRIC_AUTH_MODE || "insecure"],
[
"__NEXT_PUBLIC_ETL_SERVICE__",
process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING",
],
[
"__NEXT_PUBLIC_DEPLOYMENT_MODE__",
process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted",
],
[
"__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__",
process.env.NEXT_PUBLIC_ELECTRIC_AUTH_MODE || "insecure",
],
]; ];
let filesProcessed = 0; let filesProcessed = 0;
let filesModified = 0; let filesModified = 0;
function walk(dir) { function walk(dir) {
let entries; let entries;
try { try {
entries = fs.readdirSync(dir, { withFileTypes: true }); entries = fs.readdirSync(dir, { withFileTypes: true });
} catch { } catch {
return; return;
} }
for (const entry of entries) { for (const entry of entries) {
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
walk(full); walk(full);
} else if (entry.name.endsWith(".js")) { } else if (entry.name.endsWith(".js")) {
filesProcessed++; filesProcessed++;
let content = fs.readFileSync(full, "utf8"); let content = fs.readFileSync(full, "utf8");
let changed = false; let changed = false;
for (const [placeholder, value] of replacements) { for (const [placeholder, value] of replacements) {
if (content.includes(placeholder)) { if (content.includes(placeholder)) {
content = content.replaceAll(placeholder, value); content = content.replaceAll(placeholder, value);
changed = true; changed = true;
} }
} }
if (changed) { if (changed) {
fs.writeFileSync(full, content); fs.writeFileSync(full, content);
filesModified++; filesModified++;
} }
} }
} }
} }
console.log("[entrypoint] Replacing environment variable placeholders..."); console.log("[entrypoint] Replacing environment variable placeholders...");
for (const [placeholder, value] of replacements) { for (const [placeholder, value] of replacements) {
console.log(` ${placeholder} -> ${value}`); console.log(` ${placeholder} -> ${value}`);
} }
walk(path.join(__dirname, ".next")); walk(path.join(__dirname, ".next"));
const serverJs = path.join(__dirname, "server.js"); const serverJs = path.join(__dirname, "server.js");
if (fs.existsSync(serverJs)) { if (fs.existsSync(serverJs)) {
let content = fs.readFileSync(serverJs, "utf8"); let content = fs.readFileSync(serverJs, "utf8");
let changed = false; let changed = false;
filesProcessed++; filesProcessed++;
for (const [placeholder, value] of replacements) { for (const [placeholder, value] of replacements) {
if (content.includes(placeholder)) { if (content.includes(placeholder)) {
content = content.replaceAll(placeholder, value); content = content.replaceAll(placeholder, value);
changed = true; changed = true;
} }
} }
if (changed) { if (changed) {
fs.writeFileSync(serverJs, content); fs.writeFileSync(serverJs, content);
filesModified++; filesModified++;
} }
} }
console.log( console.log(`[entrypoint] Done. Scanned ${filesProcessed} files, modified ${filesModified}.`);
`[entrypoint] Done. Scanned ${filesProcessed} files, modified ${filesModified}.`
);

View file

@ -47,7 +47,9 @@ export function useComments({ messageId, enabled = true }: UseCommentsOptions) {
if (_batchInflight && _batchTargetIds.has(messageId)) { if (_batchInflight && _batchTargetIds.has(messageId)) {
await _batchInflight; await _batchInflight;
const cached = queryClient.getQueryData<GetCommentsResponse>(cacheKeys.comments.byMessage(messageId)); const cached = queryClient.getQueryData<GetCommentsResponse>(
cacheKeys.comments.byMessage(messageId)
);
if (cached) return cached; if (cached) return cached;
} }

View 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,
};
}

View 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;
}

View file

@ -1,22 +1,17 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { DocumentSortBy, DocumentTypeEnum, SortOrder } from "@/contracts/types/document.types";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { filterNewElectricItems, getNewestTimestamp } from "@/lib/electric/baseline";
import type { SyncHandle } from "@/lib/electric/client"; import type { SyncHandle } from "@/lib/electric/client";
import { useElectricClient } from "@/lib/electric/context"; import { useElectricClient } from "@/lib/electric/context";
// Stable empty array to prevent infinite re-renders when no typeFilter is provided
const EMPTY_TYPE_FILTER: DocumentTypeEnum[] = [];
// Document status type (matches backend DocumentStatus JSONB)
export interface DocumentStatusType { export interface DocumentStatusType {
state: "ready" | "pending" | "processing" | "failed"; state: "ready" | "pending" | "processing" | "failed";
reason?: string; reason?: string;
} }
// Document from Electric sync (lightweight table columns - NO content/metadata)
interface DocumentElectric { interface DocumentElectric {
id: number; id: number;
search_space_id: number; search_space_id: number;
@ -27,7 +22,6 @@ interface DocumentElectric {
status: DocumentStatusType | null; status: DocumentStatusType | null;
} }
// Document for display (with resolved user name and email)
export interface DocumentDisplay { export interface DocumentDisplay {
id: number; id: number;
search_space_id: number; search_space_id: number;
@ -40,87 +34,86 @@ export interface DocumentDisplay {
status: DocumentStatusType; status: DocumentStatusType;
} }
/** export interface ApiDocumentInput {
* Deduplicate by ID and sort by created_at descending (newest first) id: number;
*/ search_space_id: number;
function deduplicateAndSort<T extends { id: number; created_at: string }>(items: T[]): T[] { document_type: string;
const seen = new Map<number, T>(); title: string;
for (const item of items) { created_by_id?: string | null;
// Keep the most recent version if duplicate created_by_name?: string | null;
const existing = seen.get(item.id); created_by_email?: string | null;
if (!existing || new Date(item.created_at) > new Date(existing.created_at)) { created_at: string;
seen.set(item.id, item); status?: DocumentStatusType | null;
}
}
return Array.from(seen.values()).sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
} }
/** export function toDisplayDoc(item: ApiDocumentInput): DocumentDisplay {
* Check if a document has valid/complete data return {
*/ id: item.id,
search_space_id: item.search_space_id,
document_type: item.document_type,
title: item.title,
created_by_id: item.created_by_id ?? null,
created_by_name: item.created_by_name ?? null,
created_by_email: item.created_by_email ?? null,
created_at: item.created_at,
status: item.status ?? { state: "ready" },
};
}
const EMPTY_TYPE_FILTER: DocumentTypeEnum[] = [];
const INITIAL_PAGE_SIZE = 20;
const SCROLL_PAGE_SIZE = 5;
function isValidDocument(doc: DocumentElectric): boolean { function isValidDocument(doc: DocumentElectric): boolean {
return doc.id != null && doc.title != null && doc.title !== ""; return doc.id != null && doc.title != null && doc.title !== "";
} }
/** /**
* Real-time documents hook with Electric SQL * Paginated documents hook with Electric SQL real-time updates.
* *
* Architecture (100% Reliable): * Architecture:
* 1. API is the PRIMARY source of truth - always loads first * 1. API is the PRIMARY data source fetches pages on demand
* 2. Electric provides REAL-TIME updates for additions and deletions * 2. Type counts come from a dedicated lightweight API endpoint
* 3. Use syncHandle.isUpToDate to determine if deletions can be trusted * 3. Electric provides REAL-TIME updates (new docs, deletions, status changes)
* 4. Handles bulk deletions correctly by checking sync state * 4. Server-side sorting via sort_by + sort_order params
* *
* Filtering strategy: * @param searchSpaceId - The search space to load documents for
* - Internal state always stores ALL documents (unfiltered) * @param typeFilter - Document types to filter by (server-side)
* - typeFilter is applied client-side when returning documents * @param sortBy - Column to sort by (server-side)
* - typeCounts always reflect the full dataset so the filter sidebar stays complete * @param sortOrder - Sort direction (server-side)
* - Changing filters is instant (no API re-fetch or Electric re-sync)
*
* @param searchSpaceId - The search space ID to filter documents
* @param typeFilter - Optional document types to filter by (applied client-side)
*/ */
export function useDocuments( export function useDocuments(
searchSpaceId: number | null, searchSpaceId: number | null,
typeFilter: DocumentTypeEnum[] = EMPTY_TYPE_FILTER typeFilter: DocumentTypeEnum[] = EMPTY_TYPE_FILTER,
sortBy: DocumentSortBy = "created_at",
sortOrder: SortOrder = "desc"
) { ) {
const electricClient = useElectricClient(); const electricClient = useElectricClient();
// Internal state: ALL documents (unfiltered) const [documents, setDocuments] = useState<DocumentDisplay[]>([]);
const [allDocuments, setAllDocuments] = useState<DocumentDisplay[]>([]); const [typeCounts, setTypeCounts] = useState<Record<string, number>>({});
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
// Track if initial API load is complete (source of truth) const apiLoadedCountRef = useRef(0);
const apiLoadedRef = useRef(false); const initialLoadDoneRef = useRef(false);
const prevParamsRef = useRef<{ sortBy: string; sortOrder: string; typeFilterKey: string } | null>(
// User cache: userId → displayName / email null
);
// Snapshot of all doc IDs from Electric's first callback after initial load.
// Anything appearing in subsequent callbacks NOT in this set is genuinely new.
const electricBaselineIdsRef = useRef<Set<number> | null>(null);
const newestApiTimestampRef = useRef<string | null>(null);
const userCacheRef = useRef<Map<string, string>>(new Map()); const userCacheRef = useRef<Map<string, string>>(new Map());
const emailCacheRef = useRef<Map<string, string>>(new Map()); const emailCacheRef = useRef<Map<string, string>>(new Map());
// Electric sync refs
const syncHandleRef = useRef<SyncHandle | null>(null); const syncHandleRef = useRef<SyncHandle | null>(null);
const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null); const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
// Type counts from ALL documents (unfiltered) — keeps filter sidebar complete const typeFilterKey = typeFilter.join(",");
const typeCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const doc of allDocuments) {
counts[doc.document_type] = (counts[doc.document_type] || 0) + 1;
}
return counts;
}, [allDocuments]);
// Client-side filtered documents for display
const documents = useMemo(() => {
if (typeFilter.length === 0) return allDocuments;
const filterSet = new Set<string>(typeFilter);
return allDocuments.filter((doc) => filterSet.has(doc.document_type));
}, [allDocuments, typeFilter]);
// Populate user cache from API response
const populateUserCache = useCallback( const populateUserCache = useCallback(
( (
items: Array<{ items: Array<{
@ -143,33 +136,11 @@ export function useDocuments(
[] []
); );
// Convert API item to display doc
const apiToDisplayDoc = useCallback( const apiToDisplayDoc = useCallback(
(item: { (item: ApiDocumentInput): DocumentDisplay => toDisplayDoc(item),
id: number;
search_space_id: number;
document_type: string;
title: string;
created_by_id?: string | null;
created_by_name?: string | null;
created_by_email?: string | null;
created_at: string;
status?: DocumentStatusType | null;
}): DocumentDisplay => ({
id: item.id,
search_space_id: item.search_space_id,
document_type: item.document_type,
title: item.title,
created_by_id: item.created_by_id ?? null,
created_by_name: item.created_by_name ?? null,
created_by_email: item.created_by_email ?? null,
created_at: item.created_at,
status: item.status ?? { state: "ready" },
}),
[] []
); );
// Convert Electric doc to display doc
const electricToDisplayDoc = useCallback( const electricToDisplayDoc = useCallback(
(doc: DocumentElectric): DocumentDisplay => ({ (doc: DocumentElectric): DocumentDisplay => ({
...doc, ...doc,
@ -184,66 +155,91 @@ export function useDocuments(
[] []
); );
// STEP 1: Load ALL documents from API (PRIMARY source of truth). // EFFECT 1: Fetch first page + type counts when params change
// Uses React Query for automatic deduplication, caching, and staleTime so // biome-ignore lint/correctness/useExhaustiveDependencies: typeFilterKey serializes typeFilter
// multiple components mounting useDocuments(sameId) share a single request.
const {
data: apiResponse,
isLoading: apiLoading,
error: apiError,
} = useQuery({
queryKey: ["documents", "all", searchSpaceId],
queryFn: () =>
documentsApiService.getDocuments({
queryParams: {
search_space_id: searchSpaceId!,
page: 0,
page_size: -1,
},
}),
enabled: !!searchSpaceId,
staleTime: 30_000,
});
// Seed local state from API response (runs once per fresh fetch)
useEffect(() => { useEffect(() => {
if (!apiResponse) return; if (!searchSpaceId) return;
populateUserCache(apiResponse.items);
const docs = apiResponse.items.map(apiToDisplayDoc);
setAllDocuments(docs);
apiLoadedRef.current = true;
setError(null);
}, [apiResponse, populateUserCache, apiToDisplayDoc]);
// Propagate loading / error from React Query let cancelled = false;
useEffect(() => {
setLoading(apiLoading);
}, [apiLoading]);
useEffect(() => { const prev = prevParamsRef.current;
if (apiError) { const isSortOnlyChange =
setError(apiError instanceof Error ? apiError : new Error("Failed to load documents")); initialLoadDoneRef.current &&
prev !== null &&
prev.typeFilterKey === typeFilterKey &&
(prev.sortBy !== sortBy || prev.sortOrder !== sortOrder);
prevParamsRef.current = { sortBy, sortOrder, typeFilterKey };
if (!isSortOnlyChange) {
setLoading(true);
setDocuments([]);
setTotal(0);
setHasMore(false);
} }
}, [apiError]); apiLoadedCountRef.current = 0;
initialLoadDoneRef.current = false;
electricBaselineIdsRef.current = null;
newestApiTimestampRef.current = null;
// EFFECT 2: Start Electric sync + live query for real-time updates const fetchInitialData = async () => {
// No type filter — syncs and queries ALL documents; filtering is client-side try {
const [docsResponse, countsResponse] = await Promise.all([
documentsApiService.getDocuments({
queryParams: {
search_space_id: searchSpaceId,
page: 0,
page_size: INITIAL_PAGE_SIZE,
...(typeFilter.length > 0 && { document_types: typeFilter }),
sort_by: sortBy,
sort_order: sortOrder,
},
}),
documentsApiService.getDocumentTypeCounts({
queryParams: { search_space_id: searchSpaceId },
}),
]);
if (cancelled) return;
populateUserCache(docsResponse.items);
const docs = docsResponse.items.map(apiToDisplayDoc);
setDocuments(docs);
setTotal(docsResponse.total);
setHasMore(docsResponse.has_more);
setTypeCounts(countsResponse);
setError(null);
apiLoadedCountRef.current = docsResponse.items.length;
newestApiTimestampRef.current = getNewestTimestamp(docsResponse.items);
initialLoadDoneRef.current = true;
} catch (err) {
if (cancelled) return;
console.error("[useDocuments] Initial load failed:", err);
setError(err instanceof Error ? err : new Error("Failed to load documents"));
} finally {
if (!cancelled) setLoading(false);
}
};
fetchInitialData();
return () => {
cancelled = true;
};
}, [searchSpaceId, typeFilterKey, sortBy, sortOrder, populateUserCache, apiToDisplayDoc]);
// EFFECT 2: Electric sync + live query for real-time updates
useEffect(() => { useEffect(() => {
if (!searchSpaceId || !electricClient) return; if (!searchSpaceId || !electricClient) return;
// Capture validated values for async closure
const spaceId = searchSpaceId; const spaceId = searchSpaceId;
const client = electricClient; const client = electricClient;
let mounted = true; let mounted = true;
async function setupElectricRealtime() { async function setupElectricRealtime() {
// Cleanup previous subscriptions
if (syncHandleRef.current) { if (syncHandleRef.current) {
try { try {
syncHandleRef.current.unsubscribe(); syncHandleRef.current.unsubscribe();
} catch { } catch {
// PGlite may already be closed during cleanup /* PGlite may already be closed */
} }
syncHandleRef.current = null; syncHandleRef.current = null;
} }
@ -251,15 +247,12 @@ export function useDocuments(
try { try {
liveQueryRef.current.unsubscribe?.(); liveQueryRef.current.unsubscribe?.();
} catch { } catch {
// PGlite may already be closed during cleanup /* PGlite may already be closed */
} }
liveQueryRef.current = null; liveQueryRef.current = null;
} }
try { try {
console.log("[useDocuments] Starting Electric sync for real-time updates");
// Start Electric sync (all documents for this search space)
const handle = await client.syncShape({ const handle = await client.syncShape({
table: "documents", table: "documents",
where: `search_space_id = ${spaceId}`, where: `search_space_id = ${spaceId}`,
@ -281,20 +274,16 @@ export function useDocuments(
} }
syncHandleRef.current = handle; syncHandleRef.current = handle;
console.log("[useDocuments] Sync started, isUpToDate:", handle.isUpToDate);
// Wait for initial sync (with timeout)
if (!handle.isUpToDate && handle.initialSyncPromise) { if (!handle.isUpToDate && handle.initialSyncPromise) {
await Promise.race([ await Promise.race([
handle.initialSyncPromise, handle.initialSyncPromise,
new Promise((resolve) => setTimeout(resolve, 5000)), new Promise((resolve) => setTimeout(resolve, 5000)),
]); ]);
console.log("[useDocuments] Initial sync complete, isUpToDate:", handle.isUpToDate);
} }
if (!mounted) return; if (!mounted) return;
// Set up live query (unfiltered — type filtering is done client-side)
const db = client.db as { const db = client.db as {
live?: { live?: {
query: <T>( query: <T>(
@ -307,13 +296,10 @@ export function useDocuments(
}; };
}; };
if (!db.live?.query) { if (!db.live?.query) return;
console.warn("[useDocuments] Live queries not available");
return;
}
const query = `SELECT id, document_type, search_space_id, title, created_by_id, created_at, status const query = `SELECT id, document_type, search_space_id, title, created_by_id, created_at, status
FROM documents FROM documents
WHERE search_space_id = $1 WHERE search_space_id = $1
ORDER BY created_at DESC`; ORDER BY created_at DESC`;
@ -324,22 +310,12 @@ export function useDocuments(
return; return;
} }
console.log("[useDocuments] Live query subscribed");
liveQuery.subscribe((result: { rows: DocumentElectric[] }) => { liveQuery.subscribe((result: { rows: DocumentElectric[] }) => {
if (!mounted || !result.rows) return; if (!mounted || !result.rows || !initialLoadDoneRef.current) return;
// DEBUG: Log first few raw documents to see what's coming from Electric
console.log("[useDocuments] Raw data sample:", result.rows.slice(0, 3));
const validItems = result.rows.filter(isValidDocument); const validItems = result.rows.filter(isValidDocument);
const isFullySynced = syncHandleRef.current?.isUpToDate ?? false; const isFullySynced = syncHandleRef.current?.isUpToDate ?? false;
console.log(
`[useDocuments] Live update: ${result.rows.length} raw, ${validItems.length} valid, synced: ${isFullySynced}`
);
// Fetch user names for new users (non-blocking)
const unknownUserIds = validItems const unknownUserIds = validItems
.filter( .filter(
(doc): doc is DocumentElectric & { created_by_id: string } => (doc): doc is DocumentElectric & { created_by_id: string } =>
@ -350,12 +326,16 @@ export function useDocuments(
if (unknownUserIds.length > 0) { if (unknownUserIds.length > 0) {
documentsApiService documentsApiService
.getDocuments({ .getDocuments({
queryParams: { search_space_id: spaceId, page: 0, page_size: 20 }, queryParams: {
search_space_id: spaceId,
page: 0,
page_size: 20,
},
}) })
.then((response) => { .then((response) => {
populateUserCache(response.items); populateUserCache(response.items);
if (mounted) { if (mounted) {
setAllDocuments((prev) => setDocuments((prev) =>
prev.map((doc) => ({ prev.map((doc) => ({
...doc, ...doc,
created_by_name: doc.created_by_id created_by_name: doc.created_by_id
@ -371,46 +351,20 @@ export function useDocuments(
.catch(() => {}); .catch(() => {});
} }
// Smart update logic based on sync state setDocuments((prev) => {
setAllDocuments((prev) => {
// Don't process if API hasn't loaded yet
if (!apiLoadedRef.current) {
console.log("[useDocuments] Waiting for API load, skipping live update");
return prev;
}
// Case 1: Live query is empty
if (validItems.length === 0) {
if (isFullySynced && prev.length > 0) {
// Electric is fully synced and says 0 items - trust it (all deleted)
console.log("[useDocuments] All documents deleted (Electric synced)");
return [];
}
// Partial sync or error - keep existing
console.log("[useDocuments] Empty live result, keeping existing");
return prev;
}
// Case 2: Electric is fully synced - TRUST IT COMPLETELY (handles bulk deletes)
if (isFullySynced) {
const liveDocs = deduplicateAndSort(validItems.map(electricToDisplayDoc));
console.log(
`[useDocuments] Synced update: ${liveDocs.length} docs (was ${prev.length})`
);
return liveDocs;
}
// Case 3: Partial sync - only ADD new items, don't remove any
const existingIds = new Set(prev.map((d) => d.id));
const liveIds = new Set(validItems.map((d) => d.id)); const liveIds = new Set(validItems.map((d) => d.id));
const prevIds = new Set(prev.map((d) => d.id));
// Find new items (in live but not in prev) const newItems = filterNewElectricItems(
const newItems = validItems validItems,
.filter((item) => !existingIds.has(item.id)) liveIds,
.map(electricToDisplayDoc); prevIds,
electricBaselineIdsRef,
newestApiTimestampRef.current
).map(electricToDisplayDoc);
// Find updated items (in both, update with latest data) // Update existing docs (status changes, title edits)
const updatedPrev = prev.map((doc) => { let updated = prev.map((doc) => {
if (liveIds.has(doc.id)) { if (liveIds.has(doc.id)) {
const liveItem = validItems.find((v) => v.id === doc.id); const liveItem = validItems.find((v) => v.id === doc.id);
if (liveItem) { if (liveItem) {
@ -420,19 +374,32 @@ export function useDocuments(
return doc; return doc;
}); });
if (newItems.length > 0) { // Remove deleted docs (only when fully synced)
console.log(`[useDocuments] Adding ${newItems.length} new items (partial sync)`); if (isFullySynced) {
return deduplicateAndSort([...newItems, ...updatedPrev]); updated = updated.filter((doc) => liveIds.has(doc.id));
} }
return updatedPrev; if (newItems.length > 0) {
return [...newItems, ...updated];
}
return updated;
}); });
// Update type counts when Electric detects changes
if (isFullySynced && validItems.length > 0) {
const counts: Record<string, number> = {};
for (const item of validItems) {
counts[item.document_type] = (counts[item.document_type] || 0) + 1;
}
setTypeCounts(counts);
setTotal(validItems.length);
}
}); });
liveQueryRef.current = liveQuery; liveQueryRef.current = liveQuery;
} catch (err) { } catch (err) {
console.error("[useDocuments] Electric setup failed:", err); console.error("[useDocuments] Electric setup failed:", err);
// Don't set error - API data is already loaded
} }
} }
@ -444,7 +411,7 @@ export function useDocuments(
try { try {
syncHandleRef.current.unsubscribe(); syncHandleRef.current.unsubscribe();
} catch { } catch {
// PGlite may already be closed during cleanup /* PGlite may already be closed */
} }
syncHandleRef.current = null; syncHandleRef.current = null;
} }
@ -452,32 +419,85 @@ export function useDocuments(
try { try {
liveQueryRef.current.unsubscribe?.(); liveQueryRef.current.unsubscribe?.();
} catch { } catch {
// PGlite may already be closed during cleanup /* PGlite may already be closed */
} }
liveQueryRef.current = null; liveQueryRef.current = null;
} }
}; };
}, [searchSpaceId, electricClient, electricToDisplayDoc, populateUserCache]); }, [searchSpaceId, electricClient, electricToDisplayDoc, populateUserCache]);
// Track previous searchSpaceId to detect actual changes // Reset on search space change
const prevSearchSpaceIdRef = useRef<number | null>(null); const prevSearchSpaceIdRef = useRef<number | null>(null);
// Reset on search space change (not on initial mount)
useEffect(() => { useEffect(() => {
if (prevSearchSpaceIdRef.current !== null && prevSearchSpaceIdRef.current !== searchSpaceId) { if (prevSearchSpaceIdRef.current !== null && prevSearchSpaceIdRef.current !== searchSpaceId) {
setAllDocuments([]); setDocuments([]);
apiLoadedRef.current = false; setTypeCounts({});
setTotal(0);
setHasMore(false);
apiLoadedCountRef.current = 0;
initialLoadDoneRef.current = false;
electricBaselineIdsRef.current = null;
newestApiTimestampRef.current = null;
userCacheRef.current.clear(); userCacheRef.current.clear();
emailCacheRef.current.clear(); emailCacheRef.current.clear();
} }
prevSearchSpaceIdRef.current = searchSpaceId; prevSearchSpaceIdRef.current = searchSpaceId;
}, [searchSpaceId]); }, [searchSpaceId]);
// Load more pages via API
// biome-ignore lint/correctness/useExhaustiveDependencies: typeFilterKey serializes typeFilter
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore || !searchSpaceId) return;
setLoadingMore(true);
try {
const response = await documentsApiService.getDocuments({
queryParams: {
search_space_id: searchSpaceId,
skip: apiLoadedCountRef.current,
page_size: SCROLL_PAGE_SIZE,
...(typeFilter.length > 0 && { document_types: typeFilter }),
sort_by: sortBy,
sort_order: sortOrder,
},
});
populateUserCache(response.items);
const newDocs = response.items.map(apiToDisplayDoc);
setDocuments((prev) => {
const existingIds = new Set(prev.map((d) => d.id));
const deduped = newDocs.filter((d) => !existingIds.has(d.id));
return [...prev, ...deduped];
});
setTotal(response.total);
setHasMore(response.has_more);
apiLoadedCountRef.current += response.items.length;
} catch (err) {
console.error("[useDocuments] Load more failed:", err);
} finally {
setLoadingMore(false);
}
}, [
loadingMore,
hasMore,
searchSpaceId,
typeFilterKey,
sortBy,
sortOrder,
populateUserCache,
apiToDisplayDoc,
]);
return { return {
documents, documents,
typeCounts, typeCounts,
total: documents.length, total,
loading, loading,
loadingMore,
hasMore,
loadMore,
error, error,
}; };
} }

View file

@ -1,497 +1,402 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; import type { InboxItem, NotificationCategory } from "@/contracts/types/inbox.types";
import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import { notificationsApiService } from "@/lib/apis/notifications-api.service";
import type { SyncHandle } from "@/lib/electric/client"; import { filterNewElectricItems, getNewestTimestamp } from "@/lib/electric/baseline";
import { useElectricClient } from "@/lib/electric/context"; import { useElectricClient } from "@/lib/electric/context";
export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; export type {
InboxItem,
InboxItemTypeEnum,
NotificationCategory,
} from "@/contracts/types/inbox.types";
const PAGE_SIZE = 50; const INITIAL_PAGE_SIZE = 50;
const SYNC_WINDOW_DAYS = 14; const SCROLL_PAGE_SIZE = 30;
const SYNC_WINDOW_DAYS = 4;
const CATEGORY_TYPE_SQL: Record<NotificationCategory, string> = {
comments: "AND type IN ('new_mention', 'comment_reply')",
status:
"AND type IN ('connector_indexing', 'connector_deletion', 'document_processing', 'page_limit_exceeded')",
};
/** /**
* Check if an item is older than the sync window * Calculate the cutoff date for sync window.
*/ * Rounds to the start of the day (midnight UTC) to ensure stable values
function isOlderThanSyncWindow(createdAt: string): boolean { * across re-renders.
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - SYNC_WINDOW_DAYS);
return new Date(createdAt) < cutoffDate;
}
/**
* Deduplicate by ID and sort by created_at descending.
* This is the SINGLE source of truth for deduplication - prevents race conditions.
*/
function deduplicateAndSort(items: InboxItem[]): InboxItem[] {
const seen = new Map<number, InboxItem>();
for (const item of items) {
if (!seen.has(item.id)) {
seen.set(item.id, item);
}
}
return Array.from(seen.values()).sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
}
/**
* Calculate the cutoff date for sync window
* IMPORTANT: Rounds to the start of the day (midnight UTC) to ensure stable values
* across re-renders. Without this, millisecond differences cause multiple syncs!
*/ */
function getSyncCutoffDate(): string { function getSyncCutoffDate(): string {
const cutoff = new Date(); const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - SYNC_WINDOW_DAYS); cutoff.setDate(cutoff.getDate() - SYNC_WINDOW_DAYS);
// Round to start of day to prevent millisecond differences causing duplicate syncs
cutoff.setUTCHours(0, 0, 0, 0); cutoff.setUTCHours(0, 0, 0, 0);
return cutoff.toISOString(); return cutoff.toISOString();
} }
/** /**
* Convert a date value to ISO string format * Hook for managing inbox items with API-first architecture + Electric real-time deltas.
*/
function toISOString(date: string | Date | null | undefined): string | null {
if (!date) return null;
if (date instanceof Date) return date.toISOString();
if (typeof date === "string") {
if (date.includes("T")) return date;
try {
return new Date(date).toISOString();
} catch {
return date;
}
}
return null;
}
/**
* Hook for managing inbox items with Electric SQL real-time sync + API fallback
* *
* Architecture (Simplified & Race-Condition Free): * Architecture (Documents pattern, per-tab):
* - Electric SQL: Syncs recent items (within SYNC_WINDOW_DAYS) for real-time updates * 1. API is the PRIMARY data source fetches first page on mount with category filter
* - Live Query: Provides reactive first page from PGLite * 2. Electric provides REAL-TIME updates (new items, status changes, read state)
* - API: Handles all pagination (more reliable than mixing with Electric) * 3. Baseline pattern prevents duplicates between API and Electric
* 4. Electric sync shape is SHARED across instances (client-level caching)
* each instance creates its own type-filtered live queries
* *
* Key Design Decisions: * Unread count strategy:
* 1. No mutable refs for cursor - cursor computed from current state * - API provides the category-filtered total on mount (ground truth across all time)
* 2. Single deduplicateAndSort function - prevents inconsistencies * - Electric live query counts unread within SYNC_WINDOW_DAYS (filtered by type)
* 3. Filter-based preservation in live query - prevents data loss * - olderUnreadOffsetRef bridges the gap: total = offset + recent
* 4. Auto-fetch from API when Electric returns 0 items * - Optimistic updates adjust both the count and the offset (for old items)
* *
* @param userId - The user ID to fetch inbox items for * @param userId - The user ID to fetch inbox items for
* @param searchSpaceId - The search space ID to filter inbox items * @param searchSpaceId - The search space ID to filter inbox items
* @param typeFilter - Optional inbox item type to filter by * @param category - Which tab: "comments" or "status"
*/ */
export function useInbox( export function useInbox(
userId: string | null, userId: string | null,
searchSpaceId: number | null, searchSpaceId: number | null,
typeFilter: InboxItemTypeEnum | null = null category: NotificationCategory
) { ) {
const electricClient = useElectricClient(); const electricClient = useElectricClient();
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]); const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(false);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
// Split unread count tracking for accurate counts with 14-day sync window const initialLoadDoneRef = useRef(false);
// olderUnreadCount = unread items OLDER than sync window (from server, static until reconciliation) const electricBaselineIdsRef = useRef<Set<number> | null>(null);
// recentUnreadCount = unread items within sync window (from live query, real-time) const newestApiTimestampRef = useRef<string | null>(null);
const [olderUnreadCount, setOlderUnreadCount] = useState(0); const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
const [recentUnreadCount, setRecentUnreadCount] = useState(0); const unreadLiveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
const syncHandleRef = useRef<SyncHandle | null>(null); const olderUnreadOffsetRef = useRef<number | null>(null);
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); const apiUnreadTotalRef = useRef(0);
const userSyncKeyRef = useRef<string | null>(null);
const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
// Total unread = older (static from server) + recent (live from Electric) // EFFECT 1: Fetch first page + unread count from API with category filter
const totalUnreadCount = olderUnreadCount + recentUnreadCount;
// EFFECT 1: Electric SQL sync for real-time updates
useEffect(() => { useEffect(() => {
if (!userId || !electricClient) { if (!userId || !searchSpaceId) return;
setLoading(!electricClient);
return;
}
let cancelled = false;
setLoading(true);
setInboxItems([]);
setHasMore(false);
initialLoadDoneRef.current = false;
electricBaselineIdsRef.current = null;
newestApiTimestampRef.current = null;
olderUnreadOffsetRef.current = null;
apiUnreadTotalRef.current = 0;
const fetchInitialData = async () => {
try {
const [notificationsResponse, unreadResponse] = await Promise.all([
notificationsApiService.getNotifications({
queryParams: {
search_space_id: searchSpaceId,
category,
limit: INITIAL_PAGE_SIZE,
},
}),
notificationsApiService.getUnreadCount(searchSpaceId, undefined, category),
]);
if (cancelled) return;
setInboxItems(notificationsResponse.items);
setHasMore(notificationsResponse.has_more);
setUnreadCount(unreadResponse.total_unread);
apiUnreadTotalRef.current = unreadResponse.total_unread;
newestApiTimestampRef.current = getNewestTimestamp(notificationsResponse.items);
setError(null);
initialLoadDoneRef.current = true;
} catch (err) {
if (cancelled) return;
console.error(`[useInbox:${category}] Initial load failed:`, err);
setError(err instanceof Error ? err : new Error("Failed to load notifications"));
} finally {
if (!cancelled) setLoading(false);
}
};
fetchInitialData();
return () => {
cancelled = true;
};
}, [userId, searchSpaceId, category]);
// EFFECT 2: Electric sync (shared shape) + per-instance type-filtered live queries
useEffect(() => {
if (!userId || !searchSpaceId || !electricClient) return;
const uid = userId;
const spaceId = searchSpaceId;
const client = electricClient; const client = electricClient;
const typeFilter = CATEGORY_TYPE_SQL[category];
let mounted = true; let mounted = true;
async function startSync() { async function setupElectricRealtime() {
// Clean up previous live queries (NOT the sync shape — it's shared)
if (liveQueryRef.current) {
try {
liveQueryRef.current.unsubscribe?.();
} catch {
/* PGlite may be closed */
}
liveQueryRef.current = null;
}
if (unreadLiveQueryRef.current) {
try {
unreadLiveQueryRef.current.unsubscribe?.();
} catch {
/* PGlite may be closed */
}
unreadLiveQueryRef.current = null;
}
try { try {
const cutoffDate = getSyncCutoffDate(); const cutoffDate = getSyncCutoffDate();
const userSyncKey = `inbox_${userId}_${cutoffDate}`;
// Skip if already syncing with this key
if (userSyncKeyRef.current === userSyncKey) return;
// Clean up previous sync
if (syncHandleRef.current) {
try {
syncHandleRef.current.unsubscribe();
} catch {
// PGlite may already be closed during cleanup
}
syncHandleRef.current = null;
}
console.log("[useInbox] Starting sync for:", userId);
userSyncKeyRef.current = userSyncKey;
// Sync shape is cached by the Electric client — multiple hook instances
// calling syncShape with the same params get the same handle.
const handle = await client.syncShape({ const handle = await client.syncShape({
table: "notifications", table: "notifications",
where: `user_id = '${userId}' AND created_at > '${cutoffDate}'`, where: `user_id = '${uid}' AND created_at > '${cutoffDate}'`,
primaryKey: ["id"], primaryKey: ["id"],
}); });
// Wait for initial sync with timeout if (!mounted) return;
if (!handle.isUpToDate && handle.initialSyncPromise) { if (!handle.isUpToDate && handle.initialSyncPromise) {
await Promise.race([ await Promise.race([
handle.initialSyncPromise, handle.initialSyncPromise,
new Promise((resolve) => setTimeout(resolve, 3000)), new Promise((resolve) => setTimeout(resolve, 5000)),
]); ]);
} }
if (!mounted) return;
const db = client.db as {
live?: {
query: <T>(
sql: string,
params?: (number | string)[]
) => Promise<{
subscribe: (cb: (result: { rows: T[] }) => void) => void;
unsubscribe?: () => void;
}>;
};
};
if (!db.live?.query) return;
// Per-instance live query filtered by category types
const itemsQuery = `SELECT * FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
AND created_at > '${cutoffDate}'
${typeFilter}
ORDER BY created_at DESC`;
const liveQuery = await db.live.query<InboxItem>(itemsQuery, [uid, spaceId]);
if (!mounted) { if (!mounted) {
handle.unsubscribe(); liveQuery.unsubscribe?.();
return; return;
} }
syncHandleRef.current = handle; liveQuery.subscribe((result: { rows: InboxItem[] }) => {
setLoading(false); if (!mounted || !result.rows || !initialLoadDoneRef.current) return;
setError(null);
} catch (err) {
if (!mounted) return;
console.error("[useInbox] Sync failed:", err);
setError(err instanceof Error ? err : new Error("Sync failed"));
setLoading(false);
}
}
startSync(); const validItems = result.rows.filter((item) => item.id != null && item.title != null);
const cutoff = new Date(getSyncCutoffDate());
return () => { const liveItemMap = new Map(validItems.map((d) => [d.id, d]));
mounted = false; const liveIds = new Set(liveItemMap.keys());
userSyncKeyRef.current = null;
if (syncHandleRef.current) {
try {
syncHandleRef.current.unsubscribe();
} catch {
// PGlite may already be closed during cleanup
}
syncHandleRef.current = null;
}
};
}, [userId, electricClient]);
// Reset when filters change setInboxItems((prev) => {
useEffect(() => { const prevIds = new Set(prev.map((d) => d.id));
setHasMore(true);
setInboxItems([]);
// Reset count states - will be refetched by the unread count effect
setOlderUnreadCount(0);
setRecentUnreadCount(0);
}, [userId, searchSpaceId, typeFilter]);
// EFFECT 2: Live query for real-time updates + auto-fetch from API if empty const newItems = filterNewElectricItems(
useEffect(() => { validItems,
if (!userId || !electricClient) return; liveIds,
prevIds,
const client = electricClient; electricBaselineIdsRef,
let mounted = true; newestApiTimestampRef.current
async function setupLiveQuery() {
// Clean up previous live query
if (liveQueryRef.current) {
try {
liveQueryRef.current.unsubscribe();
} catch {
// PGlite may already be closed during cleanup
}
liveQueryRef.current = null;
}
try {
const cutoff = getSyncCutoffDate();
const query = `SELECT * FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
AND created_at > '${cutoff}'
${typeFilter ? "AND type = $3" : ""}
ORDER BY created_at DESC
LIMIT ${PAGE_SIZE}`;
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
const db = client.db as any;
// Initial fetch from PGLite - no validation needed, schema is enforced by Electric SQL sync
const result = await client.db.query<InboxItem>(query, params);
if (mounted && result.rows) {
const items = deduplicateAndSort(result.rows);
setInboxItems(items);
// AUTO-FETCH: If Electric returned 0 items, check API for older items
// This handles the edge case where user has no recent notifications
// but has older ones outside the sync window
if (items.length === 0) {
console.log(
"[useInbox] Electric returned 0 items, checking API for older notifications"
); );
try {
// Use the API service with proper Zod validation for API responses
const data = await notificationsApiService.getNotifications({
queryParams: {
search_space_id: searchSpaceId ?? undefined,
type: typeFilter ?? undefined,
limit: PAGE_SIZE,
},
});
if (mounted) { let updated = prev.map((item) => {
if (data.items.length > 0) { const liveItem = liveItemMap.get(item.id);
setInboxItems(data.items); if (liveItem) return liveItem;
} return item;
setHasMore(data.has_more);
}
} catch (err) {
console.error("[useInbox] API fallback failed:", err);
}
}
}
// Set up live query for real-time updates
if (db.live?.query) {
const liveQuery = await db.live.query(query, params);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
if (liveQuery.subscribe) {
// Live query data comes from PGlite - no validation needed
liveQuery.subscribe((result: { rows: InboxItem[] }) => {
if (mounted && result.rows) {
const liveItems = result.rows;
setInboxItems((prev) => {
const liveItemIds = new Set(liveItems.map((item) => item.id));
// FIXED: Keep ALL items not in live result (not just slice)
// This prevents data loss when new notifications push items
// out of the LIMIT window
const itemsToKeep = prev.filter((item) => !liveItemIds.has(item.id));
return deduplicateAndSort([...liveItems, ...itemsToKeep]);
});
}
}); });
const isFullySynced = handle.isUpToDate;
if (isFullySynced) {
updated = updated.filter((item) => {
if (new Date(item.created_at) < cutoff) return true;
return liveIds.has(item.id);
});
}
if (newItems.length > 0) {
return [...newItems, ...updated];
}
return updated;
});
// Calibrate the older-unread offset using baseline items
// (items present in both Electric and the API-loaded list).
// This avoids the timing bug where new items arriving between
// the API fetch and Electric's first callback would be absorbed
// into the offset, making the count appear unchanged.
const baseline = electricBaselineIdsRef.current;
if (olderUnreadOffsetRef.current === null && baseline !== null) {
const baselineUnreadCount = validItems.filter(
(item) => baseline.has(item.id) && !item.read
).length;
olderUnreadOffsetRef.current = Math.max(
0,
apiUnreadTotalRef.current - baselineUnreadCount
);
} }
if (liveQuery.unsubscribe) { // Derive unread count from all Electric items + the older offset
liveQueryRef.current = liveQuery; if (olderUnreadOffsetRef.current !== null) {
const electricUnreadCount = validItems.filter((item) => !item.read).length;
setUnreadCount(olderUnreadOffsetRef.current + electricUnreadCount);
} }
});
liveQueryRef.current = liveQuery;
// Per-instance unread count live query filtered by category types.
// Acts as a secondary reactive path for read-status changes that
// may not trigger the items live query in all edge cases.
const countQuery = `SELECT COUNT(*) as count FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
AND created_at > '${cutoffDate}'
AND read = false
${typeFilter}`;
const countLiveQuery = await db.live.query<{ count: number | string }>(countQuery, [
uid,
spaceId,
]);
if (!mounted) {
countLiveQuery.unsubscribe?.();
return;
} }
countLiveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => {
if (!mounted || !result.rows?.[0] || !initialLoadDoneRef.current) return;
if (olderUnreadOffsetRef.current === null) return;
const liveRecentUnread = Number(result.rows[0].count) || 0;
setUnreadCount(olderUnreadOffsetRef.current + liveRecentUnread);
});
unreadLiveQueryRef.current = countLiveQuery;
} catch (err) { } catch (err) {
console.error("[useInbox] Live query error:", err); console.error(`[useInbox:${category}] Electric setup failed:`, err);
} }
} }
setupLiveQuery(); setupElectricRealtime();
return () => { return () => {
mounted = false; mounted = false;
// Only clean up live queries — sync shape is shared across instances
if (liveQueryRef.current) { if (liveQueryRef.current) {
try { try {
liveQueryRef.current.unsubscribe(); liveQueryRef.current.unsubscribe?.();
} catch { } catch {
// PGlite may already be closed during cleanup /* PGlite may be closed */
} }
liveQueryRef.current = null; liveQueryRef.current = null;
} }
}; if (unreadLiveQueryRef.current) {
}, [userId, searchSpaceId, typeFilter, electricClient]); try {
unreadLiveQueryRef.current.unsubscribe?.();
// EFFECT 3: Dedicated unread count sync with split tracking } catch {
// - Fetches server count on mount (accurate total) /* PGlite may be closed */
// - Sets up live query for recent count (real-time updates)
// - Handles items older than sync window separately
useEffect(() => {
if (!userId || !electricClient) return;
const client = electricClient;
let mounted = true;
async function setupUnreadCountSync() {
// Cleanup previous live query
if (unreadCountLiveQueryRef.current) {
unreadCountLiveQueryRef.current.unsubscribe();
unreadCountLiveQueryRef.current = null;
}
try {
// STEP 1: Fetch server counts (total and recent) - guaranteed accurate
console.log(
"[useInbox] Fetching unread count from server",
typeFilter ? `for type: ${typeFilter}` : "for all types"
);
const serverCounts = await notificationsApiService.getUnreadCount(
searchSpaceId ?? undefined,
typeFilter ?? undefined
);
if (mounted) {
// Calculate older count = total - recent
const olderCount = serverCounts.total_unread - serverCounts.recent_unread;
setOlderUnreadCount(olderCount);
setRecentUnreadCount(serverCounts.recent_unread);
console.log(
`[useInbox] Server counts: total=${serverCounts.total_unread}, recent=${serverCounts.recent_unread}, older=${olderCount}`
);
} }
unreadLiveQueryRef.current = null;
// STEP 2: Set up PGLite live query for RECENT unread count only
// This provides real-time updates for notifications within sync window
const db = client.db as any;
const cutoff = getSyncCutoffDate();
// Count query - NO LIMIT, counts all unread in synced window
const countQuery = `
SELECT COUNT(*) as count FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
AND created_at > '${cutoff}'
AND read = false
${typeFilter ? "AND type = $3" : ""}
`;
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
if (db.live?.query) {
const liveQuery = await db.live.query(countQuery, params);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
if (liveQuery.subscribe) {
liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => {
if (mounted && result.rows?.[0]) {
const liveCount = Number(result.rows[0].count) || 0;
// Update recent count from live query
// This fires in real-time when Electric syncs new/updated notifications
setRecentUnreadCount(liveCount);
}
});
}
if (liveQuery.unsubscribe) {
unreadCountLiveQueryRef.current = liveQuery;
}
}
} catch (err) {
console.error("[useInbox] Unread count sync error:", err);
// On error, counts will remain at 0 or previous values
// The items-based count will be the fallback
}
}
setupUnreadCountSync();
return () => {
mounted = false;
if (unreadCountLiveQueryRef.current) {
unreadCountLiveQueryRef.current.unsubscribe();
unreadCountLiveQueryRef.current = null;
} }
}; };
}, [userId, searchSpaceId, typeFilter, electricClient]); }, [userId, searchSpaceId, electricClient, category]);
// loadMore - Pure cursor-based pagination, no race conditions // Load more pages via API (cursor-based using before_date)
// Cursor is computed from current state, not stored in refs
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
// Removed inboxItems.length === 0 check to allow loading older items if (loadingMore || !hasMore || !userId || !searchSpaceId) return;
// when Electric returns 0 items
if (!userId || loadingMore || !hasMore) return;
setLoadingMore(true); setLoadingMore(true);
try { try {
// Cursor is computed from current state - no stale refs possible
const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null; const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null;
const beforeDate = oldestItem ? toISOString(oldestItem.created_at) : null; const beforeDate = oldestItem?.created_at ?? undefined;
console.log("[useInbox] Loading more, before:", beforeDate ?? "none (initial)"); const response = await notificationsApiService.getNotifications({
// Use the API service with proper Zod validation
const data = await notificationsApiService.getNotifications({
queryParams: { queryParams: {
search_space_id: searchSpaceId ?? undefined, search_space_id: searchSpaceId,
type: typeFilter ?? undefined, category,
before_date: beforeDate ?? undefined, before_date: beforeDate,
limit: PAGE_SIZE, limit: SCROLL_PAGE_SIZE,
}, },
}); });
if (data.items.length > 0) { const newItems = response.items;
// Functional update ensures we always merge with latest state
// Items are already validated by the API service
setInboxItems((prev) => deduplicateAndSort([...prev, ...data.items]));
}
// Use API's has_more flag setInboxItems((prev) => {
setHasMore(data.has_more); const existingIds = new Set(prev.map((d) => d.id));
const deduped = newItems.filter((d) => !existingIds.has(d.id));
return [...prev, ...deduped];
});
setHasMore(response.has_more);
} catch (err) { } catch (err) {
console.error("[useInbox] Load more failed:", err); console.error(`[useInbox:${category}] Load more failed:`, err);
} finally { } finally {
setLoadingMore(false); setLoadingMore(false);
} }
}, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]); }, [loadingMore, hasMore, userId, searchSpaceId, inboxItems, category]);
// Mark inbox item as read with optimistic update // Mark single item as read with optimistic update
// Handles both recent items (live query updates count) and older items (manual count decrement)
const markAsRead = useCallback( const markAsRead = useCallback(
async (itemId: number) => { async (itemId: number) => {
// Find the item to check if it's older than sync window
const item = inboxItems.find((i) => i.id === itemId); const item = inboxItems.find((i) => i.id === itemId);
const isOlderItem = item && !item.read && isOlderThanSyncWindow(item.created_at); if (!item || item.read) return true;
const cutoff = new Date(getSyncCutoffDate());
const isOlderItem = new Date(item.created_at) < cutoff;
// Optimistic update: mark as read immediately for instant UI feedback
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i))); setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i)));
setUnreadCount((prev) => Math.max(0, prev - 1));
// If older item, manually decrement older count if (isOlderItem && olderUnreadOffsetRef.current !== null) {
// (live query won't see items outside sync window) olderUnreadOffsetRef.current = Math.max(0, olderUnreadOffsetRef.current - 1);
if (isOlderItem) {
setOlderUnreadCount((prev) => Math.max(0, prev - 1));
} }
try { try {
// Use the API service with proper Zod validation
const result = await notificationsApiService.markAsRead({ notificationId: itemId }); const result = await notificationsApiService.markAsRead({ notificationId: itemId });
if (!result.success) { if (!result.success) {
// Rollback on error
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i))); setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
if (isOlderItem) { setUnreadCount((prev) => prev + 1);
setOlderUnreadCount((prev) => prev + 1); if (isOlderItem && olderUnreadOffsetRef.current !== null) {
olderUnreadOffsetRef.current += 1;
} }
} }
// If successful, Electric SQL will sync the change and live query will update
// This ensures eventual consistency even if optimistic update was wrong
return result.success; return result.success;
} catch (err) { } catch (err) {
console.error("Failed to mark as read:", err); console.error("Failed to mark as read:", err);
// Rollback on error
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i))); setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
if (isOlderItem) { setUnreadCount((prev) => prev + 1);
setOlderUnreadCount((prev) => prev + 1); if (isOlderItem && olderUnreadOffsetRef.current !== null) {
olderUnreadOffsetRef.current += 1;
} }
return false; return false;
} }
@ -499,49 +404,42 @@ export function useInbox(
[inboxItems] [inboxItems]
); );
// Mark all inbox items as read with optimistic update // Mark all as read with optimistic update
// Resets both older and recent counts to 0
const markAllAsRead = useCallback(async () => { const markAllAsRead = useCallback(async () => {
// Store previous counts for potential rollback const prevItems = inboxItems;
const prevOlderCount = olderUnreadCount; const prevCount = unreadCount;
const prevRecentCount = recentUnreadCount; const prevOffset = olderUnreadOffsetRef.current;
// Optimistic update: mark all as read immediately for instant UI feedback
setInboxItems((prev) => prev.map((item) => ({ ...item, read: true }))); setInboxItems((prev) => prev.map((item) => ({ ...item, read: true })));
setOlderUnreadCount(0); setUnreadCount(0);
setRecentUnreadCount(0); olderUnreadOffsetRef.current = 0;
try { try {
// Use the API service with proper Zod validation
const result = await notificationsApiService.markAllAsRead(); const result = await notificationsApiService.markAllAsRead();
if (!result.success) { if (!result.success) {
console.error("Failed to mark all as read"); setInboxItems(prevItems);
// Rollback counts on error setUnreadCount(prevCount);
setOlderUnreadCount(prevOlderCount); olderUnreadOffsetRef.current = prevOffset;
setRecentUnreadCount(prevRecentCount);
} }
// Electric SQL will sync and live query will ensure consistency
return result.success; return result.success;
} catch (err) { } catch (err) {
console.error("Failed to mark all as read:", err); console.error("Failed to mark all as read:", err);
// Rollback counts on error setInboxItems(prevItems);
setOlderUnreadCount(prevOlderCount); setUnreadCount(prevCount);
setRecentUnreadCount(prevRecentCount); olderUnreadOffsetRef.current = prevOffset;
return false; return false;
} }
}, [olderUnreadCount, recentUnreadCount]); }, [inboxItems, unreadCount]);
return { return {
inboxItems, inboxItems,
unreadCount: totalUnreadCount, unreadCount,
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
loading, loading,
loadingMore, loadingMore,
hasMore, hasMore,
loadMore, loadMore,
isUsingApiFallback: true, // Always use API for pagination
error, error,
}; };
} }

View 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 };
}

View file

@ -33,25 +33,37 @@ export function usePlatformShortcut() {
setReady(true); setReady(true);
}, []); }, []);
const shortcut = useCallback( const resolveKeys = useCallback(
(...keys: string[]) => { (keys: string[]) => {
if (!ready) return "";
const mod = isMac ? "⌘" : "Ctrl"; const mod = isMac ? "⌘" : "Ctrl";
const shift = isMac ? "⇧" : "Shift"; const shift = isMac ? "⇧" : "Shift";
const alt = isMac ? "⌥" : "Alt"; const alt = isMac ? "⌥" : "Alt";
const mapped = keys.map((k) => { return keys.map((k) => {
if (k === "Mod") return mod; if (k === "Mod") return mod;
if (k === "Shift") return shift; if (k === "Shift") return shift;
if (k === "Alt") return alt; if (k === "Alt") return alt;
return k; return k;
}); });
return `(${mapped.join("+")})`;
}, },
[ready, isMac] [isMac]
); );
return { shortcut, isMac, ready }; const shortcut = useCallback(
(...keys: string[]) => {
if (!ready) return "";
return `(${resolveKeys(keys).join("+")})`;
},
[ready, resolveKeys]
);
const shortcutKeys = useCallback(
(...keys: string[]) => {
if (!ready) return [];
return resolveKeys(keys);
},
[ready, resolveKeys]
);
return { shortcut, shortcutKeys, isMac, ready };
} }

View file

@ -1,9 +1,11 @@
import { import {
type GetNotificationsRequest, type GetNotificationsRequest,
type GetNotificationsResponse, type GetNotificationsResponse,
type GetSourceTypesResponse,
type GetUnreadCountResponse, type GetUnreadCountResponse,
getNotificationsRequest, getNotificationsRequest,
getNotificationsResponse, getNotificationsResponse,
getSourceTypesResponse,
getUnreadCountResponse, getUnreadCountResponse,
type InboxItemTypeEnum, type InboxItemTypeEnum,
type MarkAllNotificationsReadResponse, type MarkAllNotificationsReadResponse,
@ -12,6 +14,7 @@ import {
markAllNotificationsReadResponse, markAllNotificationsReadResponse,
markNotificationReadRequest, markNotificationReadRequest,
markNotificationReadResponse, markNotificationReadResponse,
type NotificationCategory,
} from "@/contracts/types/inbox.types"; } from "@/contracts/types/inbox.types";
import { ValidationError } from "../error"; import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service"; import { baseApiService } from "./base-api.service";
@ -42,6 +45,15 @@ class NotificationsApiService {
if (queryParams.type) { if (queryParams.type) {
params.append("type", queryParams.type); params.append("type", queryParams.type);
} }
if (queryParams.category) {
params.append("category", queryParams.category);
}
if (queryParams.source_type) {
params.append("source_type", queryParams.source_type);
}
if (queryParams.filter) {
params.append("filter", queryParams.filter);
}
if (queryParams.before_date) { if (queryParams.before_date) {
params.append("before_date", queryParams.before_date); params.append("before_date", queryParams.before_date);
} }
@ -92,16 +104,33 @@ class NotificationsApiService {
return baseApiService.patch("/api/v1/notifications/read-all", markAllNotificationsReadResponse); return baseApiService.patch("/api/v1/notifications/read-all", markAllNotificationsReadResponse);
}; };
/**
* Get distinct source types (connector + document types) across all
* status notifications. Used to populate the inbox Status tab filter.
*/
getSourceTypes = async (searchSpaceId?: number): Promise<GetSourceTypesResponse> => {
const params = new URLSearchParams();
if (searchSpaceId !== undefined) {
params.append("search_space_id", String(searchSpaceId));
}
const queryString = params.toString();
return baseApiService.get(
`/api/v1/notifications/source-types${queryString ? `?${queryString}` : ""}`,
getSourceTypesResponse
);
};
/** /**
* Get unread notification count with split between total and recent * Get unread notification count with split between total and recent
* - total_unread: All unread notifications
* - recent_unread: Unread within sync window (last 14 days)
* @param searchSpaceId - Optional search space ID to filter by * @param searchSpaceId - Optional search space ID to filter by
* @param type - Optional notification type to filter by (type-safe enum) * @param type - Optional notification type to filter by (type-safe enum)
* @param category - Optional category filter ('comments' or 'status')
*/ */
getUnreadCount = async ( getUnreadCount = async (
searchSpaceId?: number, searchSpaceId?: number,
type?: InboxItemTypeEnum type?: InboxItemTypeEnum,
category?: NotificationCategory
): Promise<GetUnreadCountResponse> => { ): Promise<GetUnreadCountResponse> => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (searchSpaceId !== undefined) { if (searchSpaceId !== undefined) {
@ -110,6 +139,9 @@ class NotificationsApiService {
if (type) { if (type) {
params.append("type", type); params.append("type", type);
} }
if (category) {
params.append("category", category);
}
const queryString = params.toString(); const queryString = params.toString();
return baseApiService.get( return baseApiService.get(

View 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;
}

View file

@ -71,7 +71,8 @@ const pendingSyncs = new Map<string, Promise<SyncHandle>>();
// real-time documents table with title/created_by_id/status columns, // real-time documents table with title/created_by_id/status columns,
// consolidated single documents sync, pending state for document queue visibility // consolidated single documents sync, pending state for document queue visibility
// v6: added enable_summary column to search_source_connectors // v6: added enable_summary column to search_source_connectors
const SYNC_VERSION = 6; // v7: fixed connector-popup using invalid category for useInbox
const SYNC_VERSION = 7;
// Database name prefix for identifying SurfSense databases // Database name prefix for identifying SurfSense databases
const DB_PREFIX = "surfsense-"; const DB_PREFIX = "surfsense-";

View file

@ -96,5 +96,7 @@ export const cacheKeys = {
notifications: { notifications: {
search: (searchSpaceId: number | null, search: string, tab: string) => search: (searchSpaceId: number | null, search: string, tab: string) =>
["notifications", "search", searchSpaceId, search, tab] as const, ["notifications", "search", searchSpaceId, search, tab] as const,
sourceTypes: (searchSpaceId: number | null) =>
["notifications", "source-types", searchSpaceId] as const,
}, },
}; };

View file

@ -332,7 +332,8 @@
"upload_documents": "Upload Documents", "upload_documents": "Upload Documents",
"create_shared_note": "Create Shared Note", "create_shared_note": "Create Shared Note",
"processing_documents": "Processing documents...", "processing_documents": "Processing documents...",
"active_tasks_count": "{count} active task(s)" "delete_in_progress_warning": "{count} document(s) are pending or processing and cannot be deleted.",
"delete_conflict_error": "{count} document(s) started processing. Please try again later."
}, },
"add_connector": { "add_connector": {
"title": "Connect Your Tools", "title": "Connect Your Tools",
@ -637,23 +638,6 @@
"add_first_config": "Add First Configuration", "add_first_config": "Add First Configuration",
"created": "Created" "created": "Created"
}, },
"breadcrumb": {
"dashboard": "Dashboard",
"search_space": "Search Space",
"chat": "Chat",
"documents": "Documents",
"connectors": "Connectors",
"editor": "Editor",
"logs": "Logs",
"settings": "Settings",
"upload_documents": "Upload Documents",
"add_youtube": "Add YouTube Videos",
"add_webpages": "Add Webpages",
"add_connector": "Add Connector",
"manage_connectors": "Manage Connectors",
"edit_connector": "Edit Connector",
"manage": "Manage"
},
"sidebar": { "sidebar": {
"chats": "Private Chats", "chats": "Private Chats",
"shared_chats": "Shared Chats", "shared_chats": "Shared Chats",
@ -718,8 +702,11 @@
"filter": "Filter", "filter": "Filter",
"all": "All", "all": "All",
"unread": "Unread", "unread": "Unread",
"errors_only": "Errors only",
"connectors": "Connectors", "connectors": "Connectors",
"all_connectors": "All connectors", "all_connectors": "All connectors",
"sources": "Sources",
"all_sources": "All sources",
"close": "Close", "close": "Close",
"cancel": "Cancel" "cancel": "Cancel"
}, },

View file

@ -332,7 +332,8 @@
"upload_documents": "Subir documentos", "upload_documents": "Subir documentos",
"create_shared_note": "Crear nota compartida", "create_shared_note": "Crear nota compartida",
"processing_documents": "Procesando documentos...", "processing_documents": "Procesando documentos...",
"active_tasks_count": "{count} tarea(s) activa(s)" "delete_in_progress_warning": "{count} documento(s) están pendientes o en proceso y no se pueden eliminar.",
"delete_conflict_error": "{count} documento(s) comenzaron a procesarse. Inténtelo de nuevo más tarde."
}, },
"add_connector": { "add_connector": {
"title": "Conecta tus herramientas", "title": "Conecta tus herramientas",
@ -637,23 +638,6 @@
"add_first_config": "Agregar primera configuración", "add_first_config": "Agregar primera configuración",
"created": "Creado" "created": "Creado"
}, },
"breadcrumb": {
"dashboard": "Panel de control",
"search_space": "Espacio de búsqueda",
"chat": "Chat",
"documents": "Documentos",
"connectors": "Conectores",
"editor": "Editor",
"logs": "Registros",
"settings": "Configuración",
"upload_documents": "Subir documentos",
"add_youtube": "Agregar videos de YouTube",
"add_webpages": "Agregar páginas web",
"add_connector": "Agregar conector",
"manage_connectors": "Administrar conectores",
"edit_connector": "Editar conector",
"manage": "Administrar"
},
"sidebar": { "sidebar": {
"chats": "Chats privados", "chats": "Chats privados",
"shared_chats": "Chats compartidos", "shared_chats": "Chats compartidos",
@ -718,8 +702,11 @@
"filter": "Filtrar", "filter": "Filtrar",
"all": "Todo", "all": "Todo",
"unread": "No leído", "unread": "No leído",
"errors_only": "Solo errores",
"connectors": "Conectores", "connectors": "Conectores",
"all_connectors": "Todos los conectores", "all_connectors": "Todos los conectores",
"sources": "Fuentes",
"all_sources": "Todas las fuentes",
"close": "Cerrar", "close": "Cerrar",
"cancel": "Cancelar" "cancel": "Cancelar"
}, },

View file

@ -332,7 +332,8 @@
"upload_documents": "दस्तावेज़ अपलोड करें", "upload_documents": "दस्तावेज़ अपलोड करें",
"create_shared_note": "साझा नोट बनाएं", "create_shared_note": "साझा नोट बनाएं",
"processing_documents": "दस्तावेज़ प्रोसेस हो रहे हैं...", "processing_documents": "दस्तावेज़ प्रोसेस हो रहे हैं...",
"active_tasks_count": "{count} सक्रिय कार्य" "delete_in_progress_warning": "{count} दस्तावेज़ लंबित या प्रसंस्करण में हैं और हटाए नहीं जा सकते।",
"delete_conflict_error": "{count} दस्तावेज़ प्रसंस्करण शुरू हो गया है। कृपया बाद में पुनः प्रयास करें।"
}, },
"add_connector": { "add_connector": {
"title": "अपने टूल कनेक्ट करें", "title": "अपने टूल कनेक्ट करें",
@ -637,23 +638,6 @@
"add_first_config": "पहली कॉन्फ़िगरेशन जोड़ें", "add_first_config": "पहली कॉन्फ़िगरेशन जोड़ें",
"created": "बनाया गया" "created": "बनाया गया"
}, },
"breadcrumb": {
"dashboard": "डैशबोर्ड",
"search_space": "सर्च स्पेस",
"chat": "चैट",
"documents": "दस्तावेज़",
"connectors": "कनेक्टर",
"editor": "एडिटर",
"logs": "लॉग",
"settings": "सेटिंग्स",
"upload_documents": "दस्तावेज़ अपलोड करें",
"add_youtube": "YouTube वीडियो जोड़ें",
"add_webpages": "वेबपेज जोड़ें",
"add_connector": "कनेक्टर जोड़ें",
"manage_connectors": "कनेक्टर प्रबंधित करें",
"edit_connector": "कनेक्टर संपादित करें",
"manage": "प्रबंधित करें"
},
"sidebar": { "sidebar": {
"chats": "निजी चैट", "chats": "निजी चैट",
"shared_chats": "साझा चैट", "shared_chats": "साझा चैट",
@ -718,8 +702,11 @@
"filter": "फ़िल्टर", "filter": "फ़िल्टर",
"all": "सभी", "all": "सभी",
"unread": "अपठित", "unread": "अपठित",
"errors_only": "केवल त्रुटियाँ",
"connectors": "कनेक्टर", "connectors": "कनेक्टर",
"all_connectors": "सभी कनेक्टर", "all_connectors": "सभी कनेक्टर",
"sources": "स्रोत",
"all_sources": "सभी स्रोत",
"close": "बंद करें", "close": "बंद करें",
"cancel": "रद्द करें" "cancel": "रद्द करें"
}, },

View file

@ -332,7 +332,8 @@
"upload_documents": "Enviar documentos", "upload_documents": "Enviar documentos",
"create_shared_note": "Criar nota compartilhada", "create_shared_note": "Criar nota compartilhada",
"processing_documents": "Processando documentos...", "processing_documents": "Processando documentos...",
"active_tasks_count": "{count} tarefa(s) ativa(s)" "delete_in_progress_warning": "{count} documento(s) estão pendentes ou em processamento e não podem ser excluídos.",
"delete_conflict_error": "{count} documento(s) começaram a ser processados. Tente novamente mais tarde."
}, },
"add_connector": { "add_connector": {
"title": "Conecte suas ferramentas", "title": "Conecte suas ferramentas",
@ -637,23 +638,6 @@
"add_first_config": "Adicionar primeira configuração", "add_first_config": "Adicionar primeira configuração",
"created": "Criado" "created": "Criado"
}, },
"breadcrumb": {
"dashboard": "Painel",
"search_space": "Espaço de pesquisa",
"chat": "Chat",
"documents": "Documentos",
"connectors": "Conectores",
"editor": "Editor",
"logs": "Logs",
"settings": "Configurações",
"upload_documents": "Enviar documentos",
"add_youtube": "Adicionar vídeos do YouTube",
"add_webpages": "Adicionar páginas web",
"add_connector": "Adicionar conector",
"manage_connectors": "Gerenciar conectores",
"edit_connector": "Editar conector",
"manage": "Gerenciar"
},
"sidebar": { "sidebar": {
"chats": "Chats privados", "chats": "Chats privados",
"shared_chats": "Chats compartilhados", "shared_chats": "Chats compartilhados",
@ -718,8 +702,11 @@
"filter": "Filtrar", "filter": "Filtrar",
"all": "Tudo", "all": "Tudo",
"unread": "Não lido", "unread": "Não lido",
"errors_only": "Apenas erros",
"connectors": "Conectores", "connectors": "Conectores",
"all_connectors": "Todos os conectores", "all_connectors": "Todos os conectores",
"sources": "Fontes",
"all_sources": "Todas as fontes",
"close": "Fechar", "close": "Fechar",
"cancel": "Cancelar" "cancel": "Cancelar"
}, },

View file

@ -316,7 +316,8 @@
"upload_documents": "上传文档", "upload_documents": "上传文档",
"create_shared_note": "创建共享笔记", "create_shared_note": "创建共享笔记",
"processing_documents": "正在处理文档...", "processing_documents": "正在处理文档...",
"active_tasks_count": "{count} 个正在进行的工作项" "delete_in_progress_warning": "{count} 个文档正在等待或处理中,无法删除。",
"delete_conflict_error": "{count} 个文档已开始处理,请稍后再试。"
}, },
"add_connector": { "add_connector": {
"title": "连接您的工具", "title": "连接您的工具",
@ -621,23 +622,6 @@
"add_first_config": "添加首个配置", "add_first_config": "添加首个配置",
"created": "创建于" "created": "创建于"
}, },
"breadcrumb": {
"dashboard": "仪表盘",
"search_space": "搜索空间",
"chat": "聊天",
"documents": "文档",
"connectors": "连接器",
"editor": "编辑器",
"logs": "日志",
"settings": "设置",
"upload_documents": "上传文档",
"add_youtube": "添加 YouTube 视频",
"add_webpages": "添加网页",
"add_connector": "添加连接器",
"manage_connectors": "管理连接器",
"edit_connector": "编辑连接器",
"manage": "管理"
},
"sidebar": { "sidebar": {
"chats": "私人对话", "chats": "私人对话",
"shared_chats": "共享对话", "shared_chats": "共享对话",
@ -702,8 +686,11 @@
"filter": "筛选", "filter": "筛选",
"all": "全部", "all": "全部",
"unread": "未读", "unread": "未读",
"errors_only": "仅错误",
"connectors": "连接器", "connectors": "连接器",
"all_connectors": "所有连接器", "all_connectors": "所有连接器",
"sources": "来源",
"all_sources": "所有来源",
"close": "关闭", "close": "关闭",
"cancel": "取消" "cancel": "取消"
}, },

View file

@ -138,7 +138,7 @@
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.1.2", "@biomejs/biome": "2.4.6",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",

View file

@ -355,8 +355,8 @@ importers:
version: 5.0.11(@types/react@19.2.14)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) version: 5.0.11(@types/react@19.2.14)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
devDependencies: devDependencies:
'@biomejs/biome': '@biomejs/biome':
specifier: 2.1.2 specifier: 2.4.6
version: 2.1.2 version: 2.4.6
'@eslint/eslintrc': '@eslint/eslintrc':
specifier: ^3.3.1 specifier: ^3.3.1
version: 3.3.3 version: 3.3.3
@ -1080,55 +1080,55 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@biomejs/biome@2.1.2': '@biomejs/biome@2.4.6':
resolution: {integrity: sha512-yq8ZZuKuBVDgAS76LWCfFKHSYIAgqkxVB3mGVVpOe2vSkUTs7xG46zXZeNPRNVjiJuw0SZ3+J2rXiYx0RUpfGg==} resolution: {integrity: sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
hasBin: true hasBin: true
'@biomejs/cli-darwin-arm64@2.1.2': '@biomejs/cli-darwin-arm64@2.4.6':
resolution: {integrity: sha512-leFAks64PEIjc7MY/cLjE8u5OcfBKkcDB0szxsWUB4aDfemBep1WVKt0qrEyqZBOW8LPHzrFMyDl3FhuuA0E7g==} resolution: {integrity: sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@biomejs/cli-darwin-x64@2.1.2': '@biomejs/cli-darwin-x64@2.4.6':
resolution: {integrity: sha512-Nmmv7wRX5Nj7lGmz0FjnWdflJg4zii8Ivruas6PBKzw5SJX/q+Zh2RfnO+bBnuKLXpj8kiI2x2X12otpH6a32A==} resolution: {integrity: sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.1.2': '@biomejs/cli-linux-arm64-musl@2.4.6':
resolution: {integrity: sha512-qgHvafhjH7Oca114FdOScmIKf1DlXT1LqbOrrbR30kQDLFPEOpBG0uzx6MhmsrmhGiCFCr2obDamu+czk+X0HQ==} resolution: {integrity: sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@biomejs/cli-linux-arm64@2.1.2': '@biomejs/cli-linux-arm64@2.4.6':
resolution: {integrity: sha512-NWNy2Diocav61HZiv2enTQykbPP/KrA/baS7JsLSojC7Xxh2nl9IczuvE5UID7+ksRy2e7yH7klm/WkA72G1dw==} resolution: {integrity: sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@biomejs/cli-linux-x64-musl@2.1.2': '@biomejs/cli-linux-x64-musl@2.4.6':
resolution: {integrity: sha512-xlB3mU14ZUa3wzLtXfmk2IMOGL+S0aHFhSix/nssWS/2XlD27q+S6f0dlQ8WOCbYoXcuz8BCM7rCn2lxdTrlQA==} resolution: {integrity: sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@biomejs/cli-linux-x64@2.1.2': '@biomejs/cli-linux-x64@2.4.6':
resolution: {integrity: sha512-Km/UYeVowygTjpX6sGBzlizjakLoMQkxWbruVZSNE6osuSI63i4uCeIL+6q2AJlD3dxoiBJX70dn1enjQnQqwA==} resolution: {integrity: sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@biomejs/cli-win32-arm64@2.1.2': '@biomejs/cli-win32-arm64@2.4.6':
resolution: {integrity: sha512-G8KWZli5ASOXA3yUQgx+M4pZRv3ND16h77UsdunUL17uYpcL/UC7RkWTdkfvMQvogVsAuz5JUcBDjgZHXxlKoA==} resolution: {integrity: sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@biomejs/cli-win32-x64@2.1.2': '@biomejs/cli-win32-x64@2.4.6':
resolution: {integrity: sha512-9zajnk59PMpjBkty3bK2IrjUsUHvqe9HWwyAWQBjGLE7MIBjbX2vwv1XPEhmO2RRuGoTkVx3WCanHrjAytICLA==} resolution: {integrity: sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@ -7971,39 +7971,39 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@biomejs/biome@2.1.2': '@biomejs/biome@2.4.6':
optionalDependencies: optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.1.2 '@biomejs/cli-darwin-arm64': 2.4.6
'@biomejs/cli-darwin-x64': 2.1.2 '@biomejs/cli-darwin-x64': 2.4.6
'@biomejs/cli-linux-arm64': 2.1.2 '@biomejs/cli-linux-arm64': 2.4.6
'@biomejs/cli-linux-arm64-musl': 2.1.2 '@biomejs/cli-linux-arm64-musl': 2.4.6
'@biomejs/cli-linux-x64': 2.1.2 '@biomejs/cli-linux-x64': 2.4.6
'@biomejs/cli-linux-x64-musl': 2.1.2 '@biomejs/cli-linux-x64-musl': 2.4.6
'@biomejs/cli-win32-arm64': 2.1.2 '@biomejs/cli-win32-arm64': 2.4.6
'@biomejs/cli-win32-x64': 2.1.2 '@biomejs/cli-win32-x64': 2.4.6
'@biomejs/cli-darwin-arm64@2.1.2': '@biomejs/cli-darwin-arm64@2.4.6':
optional: true optional: true
'@biomejs/cli-darwin-x64@2.1.2': '@biomejs/cli-darwin-x64@2.4.6':
optional: true optional: true
'@biomejs/cli-linux-arm64-musl@2.1.2': '@biomejs/cli-linux-arm64-musl@2.4.6':
optional: true optional: true
'@biomejs/cli-linux-arm64@2.1.2': '@biomejs/cli-linux-arm64@2.4.6':
optional: true optional: true
'@biomejs/cli-linux-x64-musl@2.1.2': '@biomejs/cli-linux-x64-musl@2.4.6':
optional: true optional: true
'@biomejs/cli-linux-x64@2.1.2': '@biomejs/cli-linux-x64@2.4.6':
optional: true optional: true
'@biomejs/cli-win32-arm64@2.1.2': '@biomejs/cli-win32-arm64@2.4.6':
optional: true optional: true
'@biomejs/cli-win32-x64@2.1.2': '@biomejs/cli-win32-x64@2.4.6':
optional: true optional: true
'@date-fns/tz@1.4.1': {} '@date-fns/tz@1.4.1': {}