mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-01 03:46:25 +02:00
Merge remote-tracking branch 'upstream/dev' into fix/github-and-ui-fixes
This commit is contained in:
commit
701bb7063f
89 changed files with 3627 additions and 3951 deletions
|
|
@ -1,14 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Bell,
|
||||
ExternalLink,
|
||||
Info,
|
||||
type LucideIcon,
|
||||
Rocket,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Bell, ExternalLink, Info, type LucideIcon, Rocket, Wrench, Zap } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -114,4 +106,3 @@ export function AnnouncementCard({ announcement }: { announcement: AnnouncementW
|
|||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,4 +15,3 @@ export function AnnouncementsEmptyState() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useAtomValue } from "jotai";
|
|||
import { AlertTriangle, Cable, Settings } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import type { FC } from "react";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
|
|
@ -37,7 +37,7 @@ import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
|
|||
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
||||
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
||||
|
||||
export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger = false }) => {
|
||||
export const ConnectorIndicator: FC = () => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const searchParams = useSearchParams();
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
|
|
@ -66,11 +66,15 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
|||
const { data: documentTypeCounts, isFetching: documentTypesLoading } =
|
||||
useAtomValue(documentTypeCountsAtom);
|
||||
|
||||
// Fetch notifications to detect indexing failures
|
||||
const { inboxItems = [] } = useInbox(
|
||||
// Fetch status notifications to detect indexing failures
|
||||
const { inboxItems: statusInboxItems = [] } = useInbox(
|
||||
currentUser?.id ?? null,
|
||||
searchSpaceId ? Number(searchSpaceId) : null,
|
||||
"connector_indexing"
|
||||
"status"
|
||||
);
|
||||
const inboxItems = useMemo(
|
||||
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
|
||||
[statusInboxItems]
|
||||
);
|
||||
|
||||
// Check if YouTube view is active
|
||||
|
|
@ -189,40 +193,36 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
{!hideTrigger && (
|
||||
<TooltipIconButton
|
||||
data-joyride="connector-icon"
|
||||
tooltip={
|
||||
hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"
|
||||
}
|
||||
side="bottom"
|
||||
className={cn(
|
||||
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
||||
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
|
||||
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
|
||||
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
|
||||
)}
|
||||
aria-label={
|
||||
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
|
||||
}
|
||||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<Cable className="size-4 stroke-[1.5px]" />
|
||||
{activeConnectorsCount > 0 && (
|
||||
<span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm">
|
||||
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
<TooltipIconButton
|
||||
data-joyride="connector-icon"
|
||||
tooltip={hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"}
|
||||
side="bottom"
|
||||
className={cn(
|
||||
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
||||
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
|
||||
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
|
||||
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
|
||||
)}
|
||||
aria-label={
|
||||
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
|
||||
}
|
||||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<Cable className="size-4 stroke-[1.5px]" />
|
||||
{activeConnectorsCount > 0 && (
|
||||
<span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm select-none">
|
||||
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TooltipIconButton>
|
||||
|
||||
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none">
|
||||
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none">
|
||||
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
{isYouTubeView && searchSpaceId ? (
|
||||
|
|
@ -415,7 +415,6 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
|||
activeDocumentTypes={activeDocumentTypes}
|
||||
connectors={connectors as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onTabChange={handleTabChange}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowRight, Cable } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Cable } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
|
||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants";
|
||||
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
||||
|
|
@ -25,37 +20,21 @@ interface ActiveConnectorsTabProps {
|
|||
activeDocumentTypes: Array<[string, number]>;
|
||||
connectors: SearchSourceConnector[];
|
||||
indexingConnectorIds: Set<number>;
|
||||
searchSpaceId: string;
|
||||
onTabChange: (value: string) => void;
|
||||
onManage?: (connector: SearchSourceConnector) => void;
|
||||
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a connector type is indexable
|
||||
*/
|
||||
function isIndexableConnector(connectorType: string): boolean {
|
||||
const nonIndexableTypes = ["MCP_CONNECTOR"];
|
||||
return !nonIndexableTypes.includes(connectorType);
|
||||
}
|
||||
|
||||
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||
searchQuery,
|
||||
hasSources,
|
||||
activeDocumentTypes,
|
||||
connectors,
|
||||
indexingConnectorIds,
|
||||
searchSpaceId,
|
||||
onTabChange,
|
||||
onTabChange: _onTabChange,
|
||||
onManage,
|
||||
onViewAccountsList,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const handleViewAllDocuments = () => {
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
};
|
||||
|
||||
// Convert activeDocumentTypes array to Record for utility function
|
||||
const documentTypeCounts = activeDocumentTypes.reduce(
|
||||
(acc, [docType, count]) => {
|
||||
|
|
@ -300,15 +279,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Documents</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleViewAllDocuments}
|
||||
className="h-7 text-xs text-muted-foreground hover:text-foreground gap-1.5"
|
||||
>
|
||||
View all documents
|
||||
<ArrowRight className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{standaloneDocuments.map((doc) => (
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { TagInput, type Tag as TagType } from "emblor";
|
||||
import { useAtom } from "jotai";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type FC, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -24,7 +23,6 @@ interface YouTubeCrawlerViewProps {
|
|||
|
||||
export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId, onBack }) => {
|
||||
const t = useTranslations("add_youtube");
|
||||
const router = useRouter();
|
||||
const [videoTags, setVideoTags] = useState<TagType[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -74,9 +72,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
toast(t("success_toast"), {
|
||||
description: t("success_toast_desc"),
|
||||
});
|
||||
// Close the popup and navigate to documents
|
||||
onBack();
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = error instanceof Error ? error.message : t("error_generic");
|
||||
|
|
|
|||
|
|
@ -120,7 +120,12 @@ const DocumentUploadPopupContent: FC<{
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5">
|
||||
<DialogContent
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"
|
||||
>
|
||||
<DialogTitle className="sr-only">Upload Document</DialogTitle>
|
||||
|
||||
{/* Scrollable container for mobile */}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export interface InlineMentionEditorRef {
|
|||
getText: () => string;
|
||||
getMentionedDocuments: () => MentionedDocument[];
|
||||
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
||||
removeDocumentChip: (docId: number, docType?: string) => void;
|
||||
setDocumentChipStatus: (
|
||||
docId: number,
|
||||
docType: string | undefined,
|
||||
|
|
@ -175,33 +176,27 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
|
||||
chip.contentEditable = "false";
|
||||
chip.className =
|
||||
"inline-flex items-center gap-1 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none";
|
||||
"inline-flex items-center gap-1 mx-0.5 px-1 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none cursor-default";
|
||||
chip.style.userSelect = "none";
|
||||
chip.style.verticalAlign = "baseline";
|
||||
|
||||
// Add document type icon
|
||||
// Container that swaps between icon and remove button on hover
|
||||
const iconContainer = document.createElement("span");
|
||||
iconContainer.className = "shrink-0 flex items-center size-3 relative";
|
||||
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.className = "shrink-0 flex items-center text-muted-foreground";
|
||||
iconSpan.className = "flex items-center text-muted-foreground";
|
||||
iconSpan.innerHTML = ReactDOMServer.renderToString(
|
||||
getConnectorIcon(doc.document_type ?? "UNKNOWN", "h-3 w-3")
|
||||
);
|
||||
|
||||
const titleSpan = document.createElement("span");
|
||||
titleSpan.className = "max-w-[120px] truncate";
|
||||
titleSpan.textContent = doc.title;
|
||||
titleSpan.title = doc.title;
|
||||
titleSpan.setAttribute("data-mention-title", "true");
|
||||
|
||||
const statusSpan = document.createElement("span");
|
||||
statusSpan.setAttribute(CHIP_STATUS_ATTR, "true");
|
||||
statusSpan.className = "text-[10px] font-semibold opacity-80 hidden";
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.className =
|
||||
"size-3 flex items-center justify-center rounded-full hover:bg-primary/20 transition-colors ml-0.5";
|
||||
"size-3 items-center justify-center rounded-full text-muted-foreground transition-colors";
|
||||
removeBtn.style.display = "none";
|
||||
removeBtn.innerHTML = ReactDOMServer.renderToString(
|
||||
createElement(X, { className: "h-2.5 w-2.5", strokeWidth: 2.5 })
|
||||
createElement(X, { className: "h-3 w-3", strokeWidth: 2.5 })
|
||||
);
|
||||
removeBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -213,15 +208,45 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
next.delete(docKey);
|
||||
return next;
|
||||
});
|
||||
// Notify parent that a document was removed
|
||||
onDocumentRemove?.(doc.id, doc.document_type);
|
||||
focusAtEnd();
|
||||
};
|
||||
|
||||
chip.appendChild(iconSpan);
|
||||
chip.appendChild(titleSpan);
|
||||
chip.appendChild(statusSpan);
|
||||
chip.appendChild(removeBtn);
|
||||
const titleSpan = document.createElement("span");
|
||||
titleSpan.className = "max-w-[120px] truncate";
|
||||
titleSpan.textContent = doc.title;
|
||||
titleSpan.title = doc.title;
|
||||
titleSpan.setAttribute("data-mention-title", "true");
|
||||
|
||||
const statusSpan = document.createElement("span");
|
||||
statusSpan.setAttribute(CHIP_STATUS_ATTR, "true");
|
||||
statusSpan.className = "text-[10px] font-semibold opacity-80 hidden";
|
||||
|
||||
const isTouchDevice = window.matchMedia("(hover: none)").matches;
|
||||
if (isTouchDevice) {
|
||||
// Mobile: icon on left, title, X on right
|
||||
chip.appendChild(iconSpan);
|
||||
chip.appendChild(titleSpan);
|
||||
chip.appendChild(statusSpan);
|
||||
removeBtn.style.display = "flex";
|
||||
removeBtn.className += " ml-0.5";
|
||||
chip.appendChild(removeBtn);
|
||||
} else {
|
||||
// Desktop: icon/X swap on hover in the same slot
|
||||
iconContainer.appendChild(iconSpan);
|
||||
iconContainer.appendChild(removeBtn);
|
||||
chip.addEventListener("mouseenter", () => {
|
||||
iconSpan.style.display = "none";
|
||||
removeBtn.style.display = "flex";
|
||||
});
|
||||
chip.addEventListener("mouseleave", () => {
|
||||
iconSpan.style.display = "";
|
||||
removeBtn.style.display = "none";
|
||||
});
|
||||
chip.appendChild(iconContainer);
|
||||
chip.appendChild(titleSpan);
|
||||
chip.appendChild(statusSpan);
|
||||
}
|
||||
|
||||
return chip;
|
||||
},
|
||||
|
|
@ -388,6 +413,32 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
[]
|
||||
);
|
||||
|
||||
const removeDocumentChip = useCallback(
|
||||
(docId: number, docType?: string) => {
|
||||
if (!editorRef.current) return;
|
||||
const chipKey = `${docType ?? "UNKNOWN"}:${docId}`;
|
||||
const chips = editorRef.current.querySelectorAll<HTMLSpanElement>(
|
||||
`span[${CHIP_DATA_ATTR}="true"]`
|
||||
);
|
||||
for (const chip of chips) {
|
||||
if (getChipId(chip) === docId && getChipDocType(chip) === (docType ?? "UNKNOWN")) {
|
||||
chip.remove();
|
||||
break;
|
||||
}
|
||||
}
|
||||
setMentionedDocs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(chipKey);
|
||||
return next;
|
||||
});
|
||||
|
||||
const text = getText();
|
||||
const empty = text.length === 0 && mentionedDocs.size <= 1;
|
||||
setIsEmpty(empty);
|
||||
},
|
||||
[getText, mentionedDocs.size]
|
||||
);
|
||||
|
||||
// Expose methods via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => editorRef.current?.focus(),
|
||||
|
|
@ -395,6 +446,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
getText,
|
||||
getMentionedDocuments,
|
||||
insertDocumentChip,
|
||||
removeDocumentChip,
|
||||
setDocumentChipStatus,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ function ThreadListItemComponent({
|
|||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
||||
<DropdownMenuItem onClick={onDelete}>
|
||||
<TrashIcon className="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -18,23 +18,21 @@ import {
|
|||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
Dot,
|
||||
DownloadIcon,
|
||||
FileWarning,
|
||||
Paperclip,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SquareIcon,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import {
|
||||
mentionedDocumentIdsAtom,
|
||||
mentionedDocumentsAtom,
|
||||
sidebarSelectedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
|
|
@ -45,6 +43,7 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
import {
|
||||
InlineMentionEditor,
|
||||
type InlineMentionEditorRef,
|
||||
|
|
@ -63,11 +62,9 @@ import {
|
|||
} from "@/components/new-chat/document-mention-picker";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Placeholder texts that cycle in new chats when input is empty */
|
||||
|
|
@ -80,37 +77,19 @@ const CYCLING_PLACEHOLDERS = [
|
|||
"Check if this week's Slack messages reference any GitHub issues.",
|
||||
];
|
||||
|
||||
const CHAT_UPLOAD_ACCEPT =
|
||||
".pdf,.doc,.docx,.txt,.md,.markdown,.ppt,.pptx,.xls,.xlsx,.xlsm,.xlsb,.csv,.html,.htm,.xml,.rtf,.epub,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.tif,.mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm";
|
||||
|
||||
const CHAT_MAX_FILES = 10;
|
||||
const CHAT_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB per file
|
||||
const CHAT_MAX_TOTAL_SIZE_BYTES = 200 * 1024 * 1024; // 200 MB total
|
||||
|
||||
type UploadState = "pending" | "processing" | "ready" | "failed";
|
||||
|
||||
interface UploadedMentionDoc {
|
||||
id: number;
|
||||
title: string;
|
||||
document_type: Document["document_type"];
|
||||
state: UploadState;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
interface ThreadProps {
|
||||
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
||||
header?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map(), header }) => {
|
||||
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) => {
|
||||
return (
|
||||
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
|
||||
<ThreadContent header={header} />
|
||||
<ThreadContent />
|
||||
</ThinkingStepsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
||||
const ThreadContent: FC = () => {
|
||||
const showGutter = useAtomValue(showCommentsGutterAtom);
|
||||
|
||||
return (
|
||||
|
|
@ -128,8 +107,6 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
|||
showGutter && "lg:pr-30"
|
||||
)}
|
||||
>
|
||||
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
|
||||
|
||||
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
||||
<ThreadWelcome />
|
||||
</AssistantIf>
|
||||
|
|
@ -250,19 +227,13 @@ const ThreadWelcome: FC = () => {
|
|||
const Composer: FC = () => {
|
||||
// Document mention state (atoms persist across component remounts)
|
||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||
const setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom);
|
||||
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const [uploadedMentionDocs, setUploadedMentionDocs] = useState<
|
||||
Record<number, UploadedMentionDoc>
|
||||
>({});
|
||||
const [isUploadingDocs, setIsUploadingDocs] = useState(false);
|
||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const isFileDialogOpenRef = useRef(false);
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
const { search_space_id, chat_id } = useParams();
|
||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
||||
const composerRuntime = useComposerRuntime();
|
||||
const hasAutoFocusedRef = useRef(false);
|
||||
|
||||
|
|
@ -317,7 +288,7 @@ const Composer: FC = () => {
|
|||
const assistantIdsKey = useAssistantState(({ thread }) =>
|
||||
thread.messages
|
||||
.filter((m) => m.role === "assistant" && m.id?.startsWith("msg-"))
|
||||
.map((m) => m.id!.replace("msg-", ""))
|
||||
.map((m) => m.id?.replace("msg-", ""))
|
||||
.join(",")
|
||||
);
|
||||
const assistantDbMessageIds = useMemo(
|
||||
|
|
@ -337,18 +308,6 @@ const Composer: FC = () => {
|
|||
}
|
||||
}, [isThreadEmpty]);
|
||||
|
||||
// Sync mentioned document IDs to atom for inclusion in chat request payload
|
||||
useEffect(() => {
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: mentionedDocuments
|
||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||
.map((doc) => doc.id),
|
||||
document_ids: mentionedDocuments
|
||||
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
||||
.map((doc) => doc.id),
|
||||
});
|
||||
}, [mentionedDocuments, setMentionedDocumentIds]);
|
||||
|
||||
// Sync editor text with assistant-ui composer runtime
|
||||
const handleEditorChange = useCallback(
|
||||
(text: string) => {
|
||||
|
|
@ -401,75 +360,35 @@ const Composer: FC = () => {
|
|||
[showDocumentPopover]
|
||||
);
|
||||
|
||||
const uploadedMentionedDocs = useMemo(
|
||||
() => mentionedDocuments.filter((doc) => uploadedMentionDocs[doc.id]),
|
||||
[mentionedDocuments, uploadedMentionDocs]
|
||||
);
|
||||
|
||||
const blockingUploadedMentions = useMemo(
|
||||
() =>
|
||||
uploadedMentionedDocs.filter((doc) => {
|
||||
const state = uploadedMentionDocs[doc.id]?.state;
|
||||
return state === "pending" || state === "processing" || state === "failed";
|
||||
}),
|
||||
[uploadedMentionedDocs, uploadedMentionDocs]
|
||||
);
|
||||
|
||||
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (
|
||||
isThreadRunning ||
|
||||
isBlockedByOtherUser ||
|
||||
isUploadingDocs ||
|
||||
blockingUploadedMentions.length > 0
|
||||
) {
|
||||
if (isThreadRunning || isBlockedByOtherUser) {
|
||||
return;
|
||||
}
|
||||
if (!showDocumentPopover) {
|
||||
composerRuntime.send();
|
||||
editorRef.current?.clear();
|
||||
setMentionedDocuments([]);
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: [],
|
||||
document_ids: [],
|
||||
});
|
||||
setSidebarDocs([]);
|
||||
}
|
||||
}, [
|
||||
showDocumentPopover,
|
||||
isThreadRunning,
|
||||
isBlockedByOtherUser,
|
||||
isUploadingDocs,
|
||||
blockingUploadedMentions.length,
|
||||
composerRuntime,
|
||||
setMentionedDocuments,
|
||||
setMentionedDocumentIds,
|
||||
setSidebarDocs,
|
||||
]);
|
||||
|
||||
// Remove document from mentions and sync IDs to atom
|
||||
const handleDocumentRemove = useCallback(
|
||||
(docId: number, docType?: string) => {
|
||||
setMentionedDocuments((prev) => {
|
||||
const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: updated
|
||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||
.map((doc) => doc.id),
|
||||
document_ids: updated
|
||||
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
||||
.map((doc) => doc.id),
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
setUploadedMentionDocs((prev) => {
|
||||
if (!(docId in prev)) return prev;
|
||||
const { [docId]: _removed, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
setMentionedDocuments((prev) =>
|
||||
prev.filter((doc) => !(doc.id === docId && doc.document_type === docType))
|
||||
);
|
||||
},
|
||||
[setMentionedDocuments, setMentionedDocumentIds]
|
||||
[setMentionedDocuments]
|
||||
);
|
||||
|
||||
// Add selected documents from picker, insert chips, and sync IDs to atom
|
||||
const handleDocumentsMention = useCallback(
|
||||
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
||||
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
|
||||
|
|
@ -486,185 +405,14 @@ const Composer: FC = () => {
|
|||
const uniqueNewDocs = documents.filter(
|
||||
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
|
||||
);
|
||||
const updated = [...prev, ...uniqueNewDocs];
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: updated
|
||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||
.map((doc) => doc.id),
|
||||
document_ids: updated
|
||||
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
||||
.map((doc) => doc.id),
|
||||
});
|
||||
return updated;
|
||||
return [...prev, ...uniqueNewDocs];
|
||||
});
|
||||
|
||||
setMentionQuery("");
|
||||
},
|
||||
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
||||
[mentionedDocuments, setMentionedDocuments]
|
||||
);
|
||||
|
||||
const refreshUploadedDocStatuses = useCallback(
|
||||
async (documentIds: number[]) => {
|
||||
if (!search_space_id || documentIds.length === 0) return;
|
||||
const statusResponse = await documentsApiService.getDocumentsStatus({
|
||||
queryParams: {
|
||||
search_space_id: Number(search_space_id),
|
||||
document_ids: documentIds,
|
||||
},
|
||||
});
|
||||
|
||||
setUploadedMentionDocs((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const item of statusResponse.items) {
|
||||
next[item.id] = {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
document_type: item.document_type,
|
||||
state: item.status.state,
|
||||
reason: item.status.reason,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
handleDocumentsMention(
|
||||
statusResponse.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
document_type: item.document_type,
|
||||
}))
|
||||
);
|
||||
},
|
||||
[search_space_id, handleDocumentsMention]
|
||||
);
|
||||
|
||||
const handleUploadClick = useCallback(() => {
|
||||
if (isFileDialogOpenRef.current) return;
|
||||
isFileDialogOpenRef.current = true;
|
||||
uploadInputRef.current?.click();
|
||||
// Reset after a delay to handle cancellation (which doesn't fire the change event).
|
||||
setTimeout(() => {
|
||||
isFileDialogOpenRef.current = false;
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const handleUploadInputChange = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
isFileDialogOpenRef.current = false;
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = "";
|
||||
if (files.length === 0 || !search_space_id) return;
|
||||
|
||||
if (files.length > CHAT_MAX_FILES) {
|
||||
toast.error(`Too many files. Maximum ${CHAT_MAX_FILES} files per upload.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let totalSize = 0;
|
||||
for (const file of files) {
|
||||
if (file.size > CHAT_MAX_FILE_SIZE_BYTES) {
|
||||
toast.error(
|
||||
`File "${file.name}" (${(file.size / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB per-file limit.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
totalSize += file.size;
|
||||
}
|
||||
if (totalSize > CHAT_MAX_TOTAL_SIZE_BYTES) {
|
||||
toast.error(
|
||||
`Total upload size (${(totalSize / (1024 * 1024)).toFixed(1)} MB) exceeds the ${CHAT_MAX_TOTAL_SIZE_BYTES / (1024 * 1024)} MB limit.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploadingDocs(true);
|
||||
try {
|
||||
const uploadResponse = await documentsApiService.uploadDocument({
|
||||
files,
|
||||
search_space_id: Number(search_space_id),
|
||||
});
|
||||
const uploadedIds = uploadResponse.document_ids ?? [];
|
||||
const duplicateIds = uploadResponse.duplicate_document_ids ?? [];
|
||||
const idsToMention = Array.from(new Set([...uploadedIds, ...duplicateIds]));
|
||||
if (idsToMention.length === 0) {
|
||||
toast.warning("No documents were created or matched from selected files.");
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshUploadedDocStatuses(idsToMention);
|
||||
if (uploadedIds.length > 0 && duplicateIds.length > 0) {
|
||||
toast.success(
|
||||
`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""} and matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""}.`
|
||||
);
|
||||
} else if (uploadedIds.length > 0) {
|
||||
toast.success(`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""}`);
|
||||
} else {
|
||||
toast.success(
|
||||
`Matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""} and added mention${duplicateIds.length > 1 ? "s" : ""}.`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Upload failed";
|
||||
toast.error(`Upload failed: ${message}`);
|
||||
} finally {
|
||||
setIsUploadingDocs(false);
|
||||
}
|
||||
},
|
||||
[search_space_id, refreshUploadedDocStatuses]
|
||||
);
|
||||
|
||||
// Poll status for uploaded mentioned documents until all are ready or removed.
|
||||
useEffect(() => {
|
||||
const trackedIds = uploadedMentionedDocs.map((doc) => doc.id);
|
||||
const needsPolling = trackedIds.some((id) => {
|
||||
const state = uploadedMentionDocs[id]?.state;
|
||||
return state === "pending" || state === "processing";
|
||||
});
|
||||
if (!needsPolling) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
refreshUploadedDocStatuses(trackedIds).catch((error) => {
|
||||
console.error("[Composer] Failed to refresh uploaded mention statuses:", error);
|
||||
});
|
||||
}, 2500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [uploadedMentionedDocs, uploadedMentionDocs, refreshUploadedDocStatuses]);
|
||||
|
||||
// Push upload status directly onto mention chips (instead of separate status rows).
|
||||
useEffect(() => {
|
||||
for (const doc of uploadedMentionedDocs) {
|
||||
const state = uploadedMentionDocs[doc.id]?.state ?? "pending";
|
||||
const statusLabel =
|
||||
state === "ready"
|
||||
? null
|
||||
: state === "failed"
|
||||
? "failed"
|
||||
: state === "processing"
|
||||
? "indexing"
|
||||
: "queued";
|
||||
editorRef.current?.setDocumentChipStatus(doc.id, doc.document_type, statusLabel, state);
|
||||
}
|
||||
}, [uploadedMentionedDocs, uploadedMentionDocs]);
|
||||
|
||||
// Prune upload status entries that are no longer mentioned in the composer.
|
||||
useEffect(() => {
|
||||
const activeIds = new Set(mentionedDocuments.map((doc) => doc.id));
|
||||
setUploadedMentionDocs((prev) => {
|
||||
let changed = false;
|
||||
const next: Record<number, UploadedMentionDoc> = {};
|
||||
for (const [key, value] of Object.entries(prev)) {
|
||||
const id = Number(key);
|
||||
if (activeIds.has(id)) {
|
||||
next[id] = value;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [mentionedDocuments]);
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
||||
<ChatSessionStatus
|
||||
|
|
@ -688,15 +436,6 @@ const Composer: FC = () => {
|
|||
className="min-h-[24px]"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={CHAT_UPLOAD_ACCEPT}
|
||||
onChange={handleUploadInputChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Document picker popover (portal to body for proper z-index stacking) */}
|
||||
{showDocumentPopover &&
|
||||
typeof document !== "undefined" &&
|
||||
|
|
@ -722,15 +461,7 @@ const Composer: FC = () => {
|
|||
/>,
|
||||
document.body
|
||||
)}
|
||||
<ComposerAction
|
||||
isBlockedByOtherUser={isBlockedByOtherUser}
|
||||
onUploadClick={handleUploadClick}
|
||||
isUploadingDocs={isUploadingDocs}
|
||||
blockingUploadedMentionsCount={blockingUploadedMentions.length}
|
||||
hasFailedUploadedMentions={blockingUploadedMentions.some(
|
||||
(doc) => uploadedMentionDocs[doc.id]?.state === "failed"
|
||||
)}
|
||||
/>
|
||||
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
|
|
@ -738,29 +469,20 @@ const Composer: FC = () => {
|
|||
|
||||
interface ComposerActionProps {
|
||||
isBlockedByOtherUser?: boolean;
|
||||
onUploadClick: () => void;
|
||||
isUploadingDocs: boolean;
|
||||
blockingUploadedMentionsCount: number;
|
||||
hasFailedUploadedMentions: boolean;
|
||||
}
|
||||
|
||||
const ComposerAction: FC<ComposerActionProps> = ({
|
||||
isBlockedByOtherUser = false,
|
||||
onUploadClick,
|
||||
isUploadingDocs,
|
||||
blockingUploadedMentionsCount,
|
||||
hasFailedUploadedMentions,
|
||||
}) => {
|
||||
const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
|
||||
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
||||
const sidebarDocs = useAtomValue(sidebarSelectedDocumentsAtom);
|
||||
const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom);
|
||||
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||
|
||||
// Check if composer text is empty (chips are represented in mentionedDocuments atom)
|
||||
const isComposerTextEmpty = useAssistantState(({ composer }) => {
|
||||
const text = composer.text?.trim() || "";
|
||||
return text.length === 0;
|
||||
});
|
||||
const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0;
|
||||
|
||||
// Check if a model is configured
|
||||
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
||||
const { data: preferences } = useAtomValue(llmPreferencesAtom);
|
||||
|
|
@ -770,121 +492,91 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
const agentLlmId = preferences.agent_llm_id;
|
||||
if (agentLlmId === null || agentLlmId === undefined) return false;
|
||||
|
||||
// Check if the configured model actually exists
|
||||
// Auto mode (ID 0) and global configs (negative IDs) are in globalConfigs
|
||||
if (agentLlmId <= 0) {
|
||||
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}
|
||||
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}, [preferences, globalConfigs, userConfigs]);
|
||||
|
||||
const isSendDisabled =
|
||||
isComposerEmpty ||
|
||||
!hasModelConfigured ||
|
||||
isBlockedByOtherUser ||
|
||||
isUploadingDocs ||
|
||||
blockingUploadedMentionsCount > 0;
|
||||
const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
|
||||
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipIconButton
|
||||
tooltip={
|
||||
isUploadingDocs ? (
|
||||
"Uploading documents..."
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">Upload and mention files</span>
|
||||
<span className="text-xs text-muted-foreground flex items-center">
|
||||
Max 10 files <Dot className="size-3" /> 50 MB each
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Total upload limit: 200 MB</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
tooltip="Upload"
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
||||
aria-label="Upload files"
|
||||
onClick={onUploadClick}
|
||||
disabled={isUploadingDocs}
|
||||
aria-label="Upload documents"
|
||||
onClick={openUploadDialog}
|
||||
>
|
||||
{isUploadingDocs ? (
|
||||
<Spinner size="sm" className="text-muted-foreground" />
|
||||
) : (
|
||||
<Paperclip className="size-4" />
|
||||
)}
|
||||
<PlusIcon className="size-4" />
|
||||
</TooltipIconButton>
|
||||
<ConnectorIndicator />
|
||||
</div>
|
||||
|
||||
{blockingUploadedMentionsCount > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
{hasFailedUploadedMentions ? <FileWarning className="size-3" /> : <Spinner size="xs" />}
|
||||
<span>
|
||||
{hasFailedUploadedMentions
|
||||
? "Remove or retry failed uploads"
|
||||
: "Waiting for uploaded files to finish indexing"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show warning when no model is configured */}
|
||||
{!hasModelConfigured && blockingUploadedMentionsCount === 0 && (
|
||||
{!hasModelConfigured && (
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
||||
<AlertCircle className="size-3" />
|
||||
<span>Select a model</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
||||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||
<TooltipIconButton
|
||||
tooltip={
|
||||
isBlockedByOtherUser
|
||||
? "Wait for AI to finish responding"
|
||||
: hasFailedUploadedMentions
|
||||
? "Remove or retry failed uploads before sending"
|
||||
: blockingUploadedMentionsCount > 0
|
||||
? "Waiting for uploaded files to finish indexing"
|
||||
: isUploadingDocs
|
||||
? "Uploading documents..."
|
||||
: !hasModelConfigured
|
||||
? "Please select a model from the header to start chatting"
|
||||
: isComposerEmpty
|
||||
? "Enter a message to send"
|
||||
: "Send message"
|
||||
}
|
||||
side="bottom"
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"aui-composer-send size-8 rounded-full",
|
||||
isSendDisabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
aria-label="Send message"
|
||||
disabled={isSendDisabled}
|
||||
>
|
||||
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
||||
</TooltipIconButton>
|
||||
</ComposerPrimitive.Send>
|
||||
</AssistantIf>
|
||||
|
||||
<AssistantIf condition={({ thread }) => thread.isRunning}>
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<Button
|
||||
<div className="flex items-center gap-2">
|
||||
{sidebarDocs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="aui-composer-cancel size-8 rounded-full"
|
||||
aria-label="Stop generating"
|
||||
onClick={() => setDocumentsSidebarOpen(true)}
|
||||
className="rounded-full border border-border/60 bg-accent/50 px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-accent"
|
||||
>
|
||||
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
||||
</Button>
|
||||
</ComposerPrimitive.Cancel>
|
||||
</AssistantIf>
|
||||
{sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
||||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||
<TooltipIconButton
|
||||
tooltip={
|
||||
isBlockedByOtherUser
|
||||
? "Wait for AI to finish responding"
|
||||
: !hasModelConfigured
|
||||
? "Please select a model from the header to start chatting"
|
||||
: isComposerEmpty
|
||||
? "Enter a message to send"
|
||||
: "Send message"
|
||||
}
|
||||
side="bottom"
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"aui-composer-send size-8 rounded-full",
|
||||
isSendDisabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
aria-label="Send message"
|
||||
disabled={isSendDisabled}
|
||||
>
|
||||
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
||||
</TooltipIconButton>
|
||||
</ComposerPrimitive.Send>
|
||||
</AssistantIf>
|
||||
|
||||
<AssistantIf condition={({ thread }) => thread.isRunning}>
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="aui-composer-cancel size-8 rounded-full"
|
||||
aria-label="Stop generating"
|
||||
>
|
||||
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
||||
</Button>
|
||||
</ComposerPrimitive.Cancel>
|
||||
</AssistantIf>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
|
|||
)}
|
||||
{canEdit && canDelete && <DropdownMenuSeparator />}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
||||
<DropdownMenuItem onClick={onDelete}>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -1,216 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
|
||||
import { getThreadFull } from "@/lib/chat/thread-persistence";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
interface BreadcrumbItemInterface {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export function DashboardBreadcrumb() {
|
||||
const t = useTranslations("breadcrumb");
|
||||
const pathname = usePathname();
|
||||
// Extract search space ID and chat ID from pathname
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const searchSpaceId = segments[0] === "dashboard" && segments[1] ? segments[1] : null;
|
||||
|
||||
const { data: searchSpace } = useQuery({
|
||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId || ""),
|
||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
||||
enabled: !!searchSpaceId,
|
||||
});
|
||||
|
||||
// Extract chat thread ID from pathname for chat pages
|
||||
const chatThreadId = segments[2] === "new-chat" && segments[3] ? segments[3] : null;
|
||||
|
||||
// Fetch thread details when on a chat page with a thread ID
|
||||
const { data: threadData } = useQuery({
|
||||
queryKey: ["threads", searchSpaceId, "detail", chatThreadId],
|
||||
queryFn: () => getThreadFull(Number(chatThreadId)),
|
||||
enabled: !!chatThreadId && !!searchSpaceId,
|
||||
});
|
||||
|
||||
// State to store document title for editor breadcrumb
|
||||
const [documentTitle, setDocumentTitle] = useState<string | null>(null);
|
||||
|
||||
// Fetch document title when on editor page
|
||||
useEffect(() => {
|
||||
if (segments[2] === "editor" && segments[3] && searchSpaceId) {
|
||||
const documentId = segments[3];
|
||||
|
||||
// Skip fetch for "new" notes
|
||||
if (documentId === "new") {
|
||||
setDocumentTitle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getBearerToken();
|
||||
|
||||
if (token) {
|
||||
authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
|
||||
{ method: "GET" }
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.title) {
|
||||
setDocumentTitle(data.title);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// If fetch fails, just use the document ID
|
||||
setDocumentTitle(null);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setDocumentTitle(null);
|
||||
}
|
||||
}, [segments, searchSpaceId]);
|
||||
|
||||
// Parse the pathname to create breadcrumb items
|
||||
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
const breadcrumbs: BreadcrumbItemInterface[] = [];
|
||||
|
||||
// Handle search space (start directly with search space, skip "Dashboard")
|
||||
if (segments[0] === "dashboard" && segments[1]) {
|
||||
// Use the actual search space name if available, otherwise fall back to the ID
|
||||
const searchSpaceLabel = searchSpace?.name || `${t("search_space")} ${segments[1]}`;
|
||||
breadcrumbs.push({
|
||||
label: searchSpaceLabel,
|
||||
href: `/dashboard/${segments[1]}`,
|
||||
});
|
||||
|
||||
// Handle specific sections
|
||||
if (segments[2]) {
|
||||
const section = segments[2];
|
||||
let sectionLabel = section.charAt(0).toUpperCase() + section.slice(1);
|
||||
|
||||
// Map section names to more readable labels
|
||||
const sectionLabels: Record<string, string> = {
|
||||
"new-chat": t("chat") || "Chat",
|
||||
documents: t("documents"),
|
||||
logs: t("logs"),
|
||||
settings: t("settings"),
|
||||
editor: t("editor"),
|
||||
};
|
||||
|
||||
sectionLabel = sectionLabels[section] || sectionLabel;
|
||||
|
||||
// Handle sub-sections
|
||||
if (segments[3]) {
|
||||
const subSection = segments[3];
|
||||
|
||||
// Handle editor sub-sections (document ID)
|
||||
if (section === "editor") {
|
||||
// Handle special cases for editor
|
||||
let documentLabel: string;
|
||||
if (subSection === "new") {
|
||||
documentLabel = "New Note";
|
||||
} else {
|
||||
documentLabel = documentTitle || subSection;
|
||||
}
|
||||
|
||||
breadcrumbs.push({
|
||||
label: t("documents"),
|
||||
href: `/dashboard/${segments[1]}/documents`,
|
||||
});
|
||||
breadcrumbs.push({
|
||||
label: sectionLabel,
|
||||
href: `/dashboard/${segments[1]}/documents`,
|
||||
});
|
||||
breadcrumbs.push({ label: documentLabel });
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
// Handle documents sub-sections
|
||||
if (section === "documents") {
|
||||
const documentLabels: Record<string, string> = {
|
||||
upload: t("upload_documents"),
|
||||
webpage: t("add_webpages"),
|
||||
};
|
||||
|
||||
const documentLabel = documentLabels[subSection] || subSection;
|
||||
breadcrumbs.push({
|
||||
label: t("documents"),
|
||||
href: `/dashboard/${segments[1]}/documents`,
|
||||
});
|
||||
breadcrumbs.push({ label: documentLabel });
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
// Handle new-chat sub-sections (thread IDs)
|
||||
// Show the chat title if available, otherwise fall back to "Chat"
|
||||
if (section === "new-chat") {
|
||||
const chatLabel = threadData?.title || t("chat") || "Chat";
|
||||
breadcrumbs.push({
|
||||
label: chatLabel,
|
||||
});
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
// Handle other sub-sections
|
||||
let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
|
||||
const subSectionLabels: Record<string, string> = {
|
||||
upload: t("upload_documents"),
|
||||
youtube: t("add_youtube"),
|
||||
webpage: t("add_webpages"),
|
||||
manage: t("manage"),
|
||||
};
|
||||
|
||||
subSectionLabel = subSectionLabels[subSection] || subSectionLabel;
|
||||
|
||||
breadcrumbs.push({
|
||||
label: sectionLabel,
|
||||
href: `/dashboard/${segments[1]}/${section}`,
|
||||
});
|
||||
breadcrumbs.push({ label: subSectionLabel });
|
||||
} else {
|
||||
breadcrumbs.push({ label: sectionLabel });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
};
|
||||
|
||||
const breadcrumbs = generateBreadcrumbs(pathname);
|
||||
|
||||
if (breadcrumbs.length === 0) {
|
||||
return null; // Don't show breadcrumbs for root dashboard
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb className="select-none">
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<React.Fragment key={`${index}-${item.href || item.label}`}>
|
||||
<BreadcrumbItem>
|
||||
{index === breadcrumbs.length - 1 ? (
|
||||
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink href={item.href}>{item.label}</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{index < breadcrumbs.length - 1 && <BreadcrumbSeparator />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -76,7 +76,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa
|
|||
<SelectTrigger id="param-key" className="w-full">
|
||||
<SelectValue placeholder="Select parameter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="bg-muted dark:border-neutral-700">
|
||||
{PARAM_KEYS.map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{key}
|
||||
|
|
@ -104,7 +104,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa
|
|||
onClick={handleAdd}
|
||||
disabled={!selectedKey || value === ""}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" /> Add Parameter
|
||||
Add Parameter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Inbox,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
PencilIcon,
|
||||
SquareLibrary,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -32,6 +35,7 @@ import {
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { logout } from "@/lib/auth-utils";
|
||||
|
|
@ -46,7 +50,6 @@ import { LayoutShell } from "../ui/shell";
|
|||
interface LayoutDataProviderProps {
|
||||
searchSpaceId: string;
|
||||
children: React.ReactNode;
|
||||
breadcrumb?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -60,11 +63,7 @@ function formatInboxCount(count: number): string {
|
|||
return `${thousands}k+`;
|
||||
}
|
||||
|
||||
export function LayoutDataProvider({
|
||||
searchSpaceId,
|
||||
children,
|
||||
breadcrumb,
|
||||
}: LayoutDataProviderProps) {
|
||||
export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProviderProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
|
|
@ -114,40 +113,27 @@ export function LayoutDataProvider({
|
|||
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
||||
const [isInboxDocked, setIsInboxDocked] = useState(false);
|
||||
|
||||
// Documents sidebar state (shared atom so Composer can toggle it)
|
||||
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
|
||||
|
||||
// Announcements sidebar state
|
||||
const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false);
|
||||
|
||||
// Search space dialog state
|
||||
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
||||
|
||||
// Inbox hooks - separate data sources for mentions and status tabs
|
||||
// This ensures each tab has independent pagination and data loading
|
||||
// Per-tab inbox hooks — each has independent API loading, pagination,
|
||||
// and Electric live queries. The Electric sync shape is shared (client-level cache).
|
||||
const userId = user?.id ? String(user.id) : null;
|
||||
const numericSpaceId = Number(searchSpaceId) || null;
|
||||
|
||||
const {
|
||||
inboxItems: mentionItems,
|
||||
unreadCount: mentionUnreadCount,
|
||||
loading: mentionLoading,
|
||||
loadingMore: mentionLoadingMore,
|
||||
hasMore: mentionHasMore,
|
||||
loadMore: mentionLoadMore,
|
||||
markAsRead: markMentionAsRead,
|
||||
markAllAsRead: markAllMentionsAsRead,
|
||||
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
|
||||
const commentsInbox = useInbox(userId, numericSpaceId, "comments");
|
||||
const statusInbox = useInbox(userId, numericSpaceId, "status");
|
||||
|
||||
const {
|
||||
inboxItems: statusItems,
|
||||
unreadCount: allUnreadCount,
|
||||
loading: statusLoading,
|
||||
loadingMore: statusLoadingMore,
|
||||
hasMore: statusHasMore,
|
||||
loadMore: statusLoadMore,
|
||||
markAsRead: markStatusAsRead,
|
||||
markAllAsRead: markAllStatusAsRead,
|
||||
} = useInbox(userId, Number(searchSpaceId) || null, null);
|
||||
const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount;
|
||||
|
||||
const totalUnreadCount = allUnreadCount;
|
||||
const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount);
|
||||
// Whether any documents are currently being uploaded/indexed — drives sidebar spinner
|
||||
const isDocumentsProcessing = useDocumentsProcessing(numericSpaceId);
|
||||
|
||||
// Track seen notification IDs to detect new page_limit_exceeded notifications
|
||||
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
||||
|
|
@ -155,14 +141,12 @@ export function LayoutDataProvider({
|
|||
|
||||
// Effect to show toast for new page_limit_exceeded notifications
|
||||
useEffect(() => {
|
||||
if (statusLoading) return;
|
||||
if (statusInbox.loading) return;
|
||||
|
||||
// Get page_limit_exceeded notifications
|
||||
const pageLimitNotifications = statusItems.filter(
|
||||
const pageLimitNotifications = statusInbox.inboxItems.filter(
|
||||
(item) => item.type === "page_limit_exceeded"
|
||||
);
|
||||
|
||||
// On initial load, just mark all as seen without showing toasts
|
||||
if (isInitialLoad.current) {
|
||||
for (const notification of pageLimitNotifications) {
|
||||
seenPageLimitNotifications.current.add(notification.id);
|
||||
|
|
@ -171,16 +155,13 @@ export function LayoutDataProvider({
|
|||
return;
|
||||
}
|
||||
|
||||
// Find new notifications (not yet seen)
|
||||
const newNotifications = pageLimitNotifications.filter(
|
||||
(notification) => !seenPageLimitNotifications.current.has(notification.id)
|
||||
);
|
||||
|
||||
// Show toast for each new page_limit_exceeded notification
|
||||
for (const notification of newNotifications) {
|
||||
seenPageLimitNotifications.current.add(notification.id);
|
||||
|
||||
// Extract metadata for navigation
|
||||
const actionUrl = isPageLimitExceededMetadata(notification.metadata)
|
||||
? notification.metadata.action_url
|
||||
: `/dashboard/${searchSpaceId}/more-pages`;
|
||||
|
|
@ -195,24 +176,7 @@ export function LayoutDataProvider({
|
|||
},
|
||||
});
|
||||
}
|
||||
}, [statusItems, statusLoading, searchSpaceId, router]);
|
||||
|
||||
// Unified mark as read that delegates to the correct hook
|
||||
const markAsRead = useCallback(
|
||||
async (id: number) => {
|
||||
// Try both - one will succeed based on which list has the item
|
||||
const mentionResult = await markMentionAsRead(id);
|
||||
if (mentionResult) return true;
|
||||
return markStatusAsRead(id);
|
||||
},
|
||||
[markMentionAsRead, markStatusAsRead]
|
||||
);
|
||||
|
||||
// Mark all as read for both types
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
await Promise.all([markAllMentionsAsRead(), markAllStatusAsRead()]);
|
||||
return true;
|
||||
}, [markAllMentionsAsRead, markAllStatusAsRead]);
|
||||
}, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]);
|
||||
|
||||
// Delete dialogs state
|
||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||
|
|
@ -295,34 +259,35 @@ export function LayoutDataProvider({
|
|||
// Navigation items
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Documents",
|
||||
url: `/dashboard/${searchSpaceId}/documents`,
|
||||
icon: SquareLibrary,
|
||||
isActive: pathname?.includes("/documents"),
|
||||
},
|
||||
{
|
||||
title: "Inbox",
|
||||
url: "#inbox", // Special URL to indicate this is handled differently
|
||||
url: "#inbox",
|
||||
icon: Inbox,
|
||||
isActive: isInboxSidebarOpen,
|
||||
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
|
||||
},
|
||||
{
|
||||
title: "Documents",
|
||||
url: "#documents",
|
||||
icon: SquareLibrary,
|
||||
isActive: isDocumentsSidebarOpen,
|
||||
showSpinner: isDocumentsProcessing,
|
||||
},
|
||||
{
|
||||
title: "Announcements",
|
||||
url: "#announcements", // Special URL to indicate this is handled differently
|
||||
url: "#announcements",
|
||||
icon: Megaphone,
|
||||
isActive: isAnnouncementsSidebarOpen,
|
||||
badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
searchSpaceId,
|
||||
pathname,
|
||||
isInboxSidebarOpen,
|
||||
isDocumentsSidebarOpen,
|
||||
totalUnreadCount,
|
||||
isAnnouncementsSidebarOpen,
|
||||
announcementUnreadCount,
|
||||
isDocumentsProcessing,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -415,10 +380,22 @@ export function LayoutDataProvider({
|
|||
|
||||
const handleNavItemClick = useCallback(
|
||||
(item: NavItem) => {
|
||||
// Handle inbox specially - toggle sidebar instead of navigating
|
||||
if (item.url === "#inbox") {
|
||||
setIsInboxSidebarOpen((prev) => {
|
||||
if (!prev) {
|
||||
setIsAllSharedChatsSidebarOpen(false);
|
||||
setIsAllPrivateChatsSidebarOpen(false);
|
||||
setIsDocumentsSidebarOpen(false);
|
||||
setIsAnnouncementsSidebarOpen(false);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (item.url === "#documents") {
|
||||
setIsDocumentsSidebarOpen((prev) => {
|
||||
if (!prev) {
|
||||
setIsInboxSidebarOpen(false);
|
||||
setIsAllSharedChatsSidebarOpen(false);
|
||||
setIsAllPrivateChatsSidebarOpen(false);
|
||||
setIsAnnouncementsSidebarOpen(false);
|
||||
|
|
@ -427,13 +404,13 @@ export function LayoutDataProvider({
|
|||
});
|
||||
return;
|
||||
}
|
||||
// Handle announcements specially - toggle sidebar instead of navigating
|
||||
if (item.url === "#announcements") {
|
||||
setIsAnnouncementsSidebarOpen((prev) => {
|
||||
if (!prev) {
|
||||
setIsInboxSidebarOpen(false);
|
||||
setIsAllSharedChatsSidebarOpen(false);
|
||||
setIsAllPrivateChatsSidebarOpen(false);
|
||||
setIsDocumentsSidebarOpen(false);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
|
|
@ -441,13 +418,7 @@ export function LayoutDataProvider({
|
|||
}
|
||||
router.push(item.url);
|
||||
},
|
||||
[
|
||||
router,
|
||||
setIsAllPrivateChatsSidebarOpen,
|
||||
setIsAllSharedChatsSidebarOpen,
|
||||
setIsAnnouncementsSidebarOpen,
|
||||
setIsInboxSidebarOpen,
|
||||
]
|
||||
[router, setIsDocumentsSidebarOpen]
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
|
|
@ -544,15 +515,17 @@ export function LayoutDataProvider({
|
|||
setIsAllSharedChatsSidebarOpen(true);
|
||||
setIsAllPrivateChatsSidebarOpen(false);
|
||||
setIsInboxSidebarOpen(false);
|
||||
setIsDocumentsSidebarOpen(false);
|
||||
setIsAnnouncementsSidebarOpen(false);
|
||||
}, []);
|
||||
}, [setIsDocumentsSidebarOpen]);
|
||||
|
||||
const handleViewAllPrivateChats = useCallback(() => {
|
||||
setIsAllPrivateChatsSidebarOpen(true);
|
||||
setIsAllSharedChatsSidebarOpen(false);
|
||||
setIsInboxSidebarOpen(false);
|
||||
setIsDocumentsSidebarOpen(false);
|
||||
setIsAnnouncementsSidebarOpen(false);
|
||||
}, []);
|
||||
}, [setIsDocumentsSidebarOpen]);
|
||||
|
||||
// Delete handlers
|
||||
const confirmDeleteChat = useCallback(async () => {
|
||||
|
|
@ -583,10 +556,6 @@ export function LayoutDataProvider({
|
|||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
// Invalidate thread detail for breadcrumb update
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error renaming thread:", error);
|
||||
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
|
||||
|
|
@ -641,7 +610,6 @@ export function LayoutDataProvider({
|
|||
onUserSettings={handleUserSettings}
|
||||
onLogout={handleLogout}
|
||||
pageUsage={pageUsage}
|
||||
breadcrumb={breadcrumb}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
isChatPage={isChatPage}
|
||||
|
|
@ -649,26 +617,27 @@ export function LayoutDataProvider({
|
|||
inbox={{
|
||||
isOpen: isInboxSidebarOpen,
|
||||
onOpenChange: setIsInboxSidebarOpen,
|
||||
// Separate data sources for each tab
|
||||
mentions: {
|
||||
items: mentionItems,
|
||||
unreadCount: mentionUnreadCount,
|
||||
loading: mentionLoading,
|
||||
loadingMore: mentionLoadingMore,
|
||||
hasMore: mentionHasMore,
|
||||
loadMore: mentionLoadMore,
|
||||
totalUnreadCount,
|
||||
comments: {
|
||||
items: commentsInbox.inboxItems,
|
||||
unreadCount: commentsInbox.unreadCount,
|
||||
loading: commentsInbox.loading,
|
||||
loadingMore: commentsInbox.loadingMore,
|
||||
hasMore: commentsInbox.hasMore,
|
||||
loadMore: commentsInbox.loadMore,
|
||||
markAsRead: commentsInbox.markAsRead,
|
||||
markAllAsRead: commentsInbox.markAllAsRead,
|
||||
},
|
||||
status: {
|
||||
items: statusItems,
|
||||
unreadCount: statusOnlyUnreadCount,
|
||||
loading: statusLoading,
|
||||
loadingMore: statusLoadingMore,
|
||||
hasMore: statusHasMore,
|
||||
loadMore: statusLoadMore,
|
||||
items: statusInbox.inboxItems,
|
||||
unreadCount: statusInbox.unreadCount,
|
||||
loading: statusInbox.loading,
|
||||
loadingMore: statusInbox.loadingMore,
|
||||
hasMore: statusInbox.hasMore,
|
||||
loadMore: statusInbox.loadMore,
|
||||
markAsRead: statusInbox.markAsRead,
|
||||
markAllAsRead: statusInbox.markAllAsRead,
|
||||
},
|
||||
totalUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
isDocked: isInboxDocked,
|
||||
onDockedChange: setIsInboxDocked,
|
||||
}}
|
||||
|
|
@ -686,36 +655,33 @@ export function LayoutDataProvider({
|
|||
onOpenChange: setIsAllPrivateChatsSidebarOpen,
|
||||
searchSpaceId,
|
||||
}}
|
||||
documentsPanel={{
|
||||
open: isDocumentsSidebarOpen,
|
||||
onOpenChange: setIsDocumentsSidebarOpen,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LayoutShell>
|
||||
|
||||
{/* Delete Chat Dialog */}
|
||||
<Dialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
<span>{t("delete_chat")}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<AlertDialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}>
|
||||
<AlertDialogContent className="sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("delete_chat")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "}
|
||||
{t("action_cannot_undone")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteChatDialog(false)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeletingChat}>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
confirmDeleteChat();
|
||||
}}
|
||||
disabled={isDeletingChat}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDeleteChat}
|
||||
disabled={isDeletingChat}
|
||||
className="gap-2"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||
>
|
||||
{isDeletingChat ? (
|
||||
<>
|
||||
|
|
@ -723,15 +689,12 @@ export function LayoutDataProvider({
|
|||
{t("deleting")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{tCommon("delete")}
|
||||
</>
|
||||
tCommon("delete")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Rename Chat Dialog */}
|
||||
<Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}>
|
||||
|
|
@ -756,7 +719,7 @@ export function LayoutDataProvider({
|
|||
/>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
onClick={() => setShowRenameChatDialog(false)}
|
||||
disabled={isRenamingChat}
|
||||
>
|
||||
|
|
@ -773,10 +736,7 @@ export function LayoutDataProvider({
|
|||
{tSidebar("renaming") || "Renaming"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
{tSidebar("rename") || "Rename"}
|
||||
</>
|
||||
tSidebar("rename") || "Rename"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
@ -784,30 +744,25 @@ export function LayoutDataProvider({
|
|||
</Dialog>
|
||||
|
||||
{/* Delete Search Space Dialog */}
|
||||
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
<span>{t("delete_search_space")}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<AlertDialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
|
||||
<AlertDialogContent className="sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("delete_search_space")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteSearchSpaceDialog(false)}
|
||||
disabled={isDeletingSearchSpace}
|
||||
>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeletingSearchSpace}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDeleteSearchSpace}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
confirmDeleteSearchSpace();
|
||||
}}
|
||||
disabled={isDeletingSearchSpace}
|
||||
className="gap-2"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||
>
|
||||
{isDeletingSearchSpace ? (
|
||||
<>
|
||||
|
|
@ -815,41 +770,33 @@ export function LayoutDataProvider({
|
|||
{t("deleting")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{tCommon("delete")}
|
||||
</>
|
||||
tCommon("delete")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Leave Search Space Dialog */}
|
||||
<Dialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LogOut className="h-5 w-5 text-destructive" />
|
||||
<span>{t("leave_title")}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<AlertDialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
|
||||
<AlertDialogContent className="sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("leave_title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("leave_confirm", { name: searchSpaceToLeave?.name || "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowLeaveSearchSpaceDialog(false)}
|
||||
disabled={isLeavingSearchSpace}
|
||||
>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLeavingSearchSpace}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmLeaveSearchSpace}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
confirmLeaveSearchSpace();
|
||||
}}
|
||||
disabled={isLeavingSearchSpace}
|
||||
className="gap-2"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
|
||||
>
|
||||
{isLeavingSearchSpace ? (
|
||||
<>
|
||||
|
|
@ -857,15 +804,12 @@ export function LayoutDataProvider({
|
|||
{t("leaving")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{t("leave")}
|
||||
</>
|
||||
t("leave")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Create Search Space Dialog */}
|
||||
<CreateSearchSpaceDialog
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface NavItem {
|
|||
icon: LucideIcon;
|
||||
isActive?: boolean;
|
||||
badge?: string | number;
|
||||
showSpinner?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatItem {
|
||||
|
|
|
|||
|
|
@ -138,20 +138,20 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
|
|||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex-row gap-2 pt-2 sm:pt-3">
|
||||
<DialogFooter className="flex-row justify-end gap-2 pt-2 sm:pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 sm:flex-none sm:w-auto h-8 sm:h-10 text-xs sm:text-sm"
|
||||
className="h-8 sm:h-9 text-xs sm:text-sm"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 sm:flex-none sm:w-auto h-8 sm:h-10 text-xs sm:text-sm"
|
||||
className="h-8 sm:h-9 text-xs sm:text-sm"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -3,33 +3,30 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||
|
||||
interface HeaderProps {
|
||||
breadcrumb?: React.ReactNode;
|
||||
mobileMenuTrigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
|
||||
export function Header({ mobileMenuTrigger }: HeaderProps) {
|
||||
const pathname = usePathname();
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
|
||||
// Check if we're on a chat page
|
||||
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||
|
||||
// Use Jotai atom for thread state (synced from chat page)
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
|
||||
// Show button only when we have a thread id (thread exists and is synced to Jotai)
|
||||
const hasThread = isChatPage && currentThreadState.id !== null;
|
||||
|
||||
// Create minimal thread object for ChatShareButton (used for API calls)
|
||||
const threadForButton: ThreadRecord | null =
|
||||
hasThread && currentThreadState.id !== null
|
||||
? {
|
||||
id: currentThreadState.id,
|
||||
visibility: currentThreadState.visibility ?? "PRIVATE",
|
||||
// These fields are not used by ChatShareButton for display, only for checks
|
||||
created_by_id: null,
|
||||
search_space_id: 0,
|
||||
title: "",
|
||||
|
|
@ -39,22 +36,20 @@ export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
|
|||
}
|
||||
: null;
|
||||
|
||||
const handleVisibilityChange = (_visibility: ChatVisibility) => {
|
||||
// Visibility change is handled by ChatShareButton internally via Jotai
|
||||
// This callback can be used for additional side effects if needed
|
||||
};
|
||||
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
|
||||
{/* Left side - Mobile menu trigger + Breadcrumb */}
|
||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4 md:border-b md:border-border">
|
||||
{/* Left side - Mobile menu trigger + Model selector */}
|
||||
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||
{mobileMenuTrigger}
|
||||
<div className="hidden md:block">{breadcrumb}</div>
|
||||
{isChatPage && searchSpaceId && (
|
||||
<ChatHeader searchSpaceId={Number(searchSpaceId)} className="md:h-9 md:px-4 md:text-sm" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side - Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Share button - only show on chat pages when thread exists */}
|
||||
{hasThread && (
|
||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -159,13 +159,13 @@ export function SearchSpaceAvatar({
|
|||
)}
|
||||
{onSettings && onDelete && <DropdownMenuSeparator />}
|
||||
{onDelete && isOwner && (
|
||||
<DropdownMenuItem variant="destructive" onClick={onDelete}>
|
||||
<DropdownMenuItem onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{tCommon("delete")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && !isOwner && (
|
||||
<DropdownMenuItem variant="destructive" onClick={onDelete}>
|
||||
<DropdownMenuItem onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("leave")}
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -217,13 +217,13 @@ export function SearchSpaceAvatar({
|
|||
)}
|
||||
{onSettings && onDelete && <ContextMenuSeparator />}
|
||||
{onDelete && isOwner && (
|
||||
<ContextMenuItem variant="destructive" onClick={onDelete}>
|
||||
<ContextMenuItem onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{tCommon("delete")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onDelete && !isOwner && (
|
||||
<ContextMenuItem variant="destructive" onClick={onDelete}>
|
||||
<ContextMenuItem onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("leave")}
|
||||
</ContextMenuItem>
|
||||
|
|
|
|||
|
|
@ -14,37 +14,33 @@ import {
|
|||
AllPrivateChatsSidebar,
|
||||
AllSharedChatsSidebar,
|
||||
AnnouncementsSidebar,
|
||||
DocumentsSidebar,
|
||||
InboxSidebar,
|
||||
MobileSidebar,
|
||||
MobileSidebarTrigger,
|
||||
Sidebar,
|
||||
} from "../sidebar";
|
||||
|
||||
// Tab-specific data source props
|
||||
// Per-tab data source
|
||||
interface TabDataSource {
|
||||
items: InboxItem[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
loadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
loadMore?: () => void;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
// Inbox-related props with separate data sources per tab
|
||||
// Inbox-related props — per-tab data sources with independent loading/pagination
|
||||
interface InboxProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Mentions tab data source with independent pagination */
|
||||
mentions: TabDataSource;
|
||||
/** Status tab data source with independent pagination */
|
||||
status: TabDataSource;
|
||||
/** Combined unread count for nav badge */
|
||||
totalUnreadCount: number;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
/** Whether the inbox is docked (permanent) */
|
||||
comments: TabDataSource;
|
||||
status: TabDataSource;
|
||||
isDocked?: boolean;
|
||||
/** Callback to change docked state */
|
||||
onDockedChange?: (docked: boolean) => void;
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +70,6 @@ interface LayoutShellProps {
|
|||
onUserSettings?: () => void;
|
||||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
breadcrumb?: React.ReactNode;
|
||||
theme?: string;
|
||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||
defaultCollapsed?: boolean;
|
||||
|
|
@ -99,6 +94,10 @@ interface LayoutShellProps {
|
|||
onOpenChange: (open: boolean) => void;
|
||||
searchSpaceId: string;
|
||||
};
|
||||
documentsPanel?: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function LayoutShell({
|
||||
|
|
@ -127,7 +126,6 @@ export function LayoutShell({
|
|||
onUserSettings,
|
||||
onLogout,
|
||||
pageUsage,
|
||||
breadcrumb,
|
||||
theme,
|
||||
setTheme,
|
||||
defaultCollapsed = false,
|
||||
|
|
@ -139,6 +137,7 @@ export function LayoutShell({
|
|||
isLoadingChats = false,
|
||||
allSharedChatsPanel,
|
||||
allPrivateChatsPanel,
|
||||
documentsPanel,
|
||||
}: LayoutShellProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
|
@ -162,7 +161,6 @@ export function LayoutShell({
|
|||
<TooltipProvider delayDuration={0}>
|
||||
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
||||
<Header
|
||||
breadcrumb={breadcrumb}
|
||||
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
||||
/>
|
||||
|
||||
|
|
@ -208,16 +206,22 @@ export function LayoutShell({
|
|||
<InboxSidebar
|
||||
open={inbox.isOpen}
|
||||
onOpenChange={inbox.onOpenChange}
|
||||
mentions={inbox.mentions}
|
||||
comments={inbox.comments}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
markAsRead={inbox.markAsRead}
|
||||
markAllAsRead={inbox.markAllAsRead}
|
||||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Announcements Sidebar - only render when open to avoid scroll blocking */}
|
||||
{/* Mobile Documents Sidebar - slide-out panel */}
|
||||
{documentsPanel && (
|
||||
<DocumentsSidebar
|
||||
open={documentsPanel.open}
|
||||
onOpenChange={documentsPanel.onOpenChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Announcements Sidebar */}
|
||||
{announcementsPanel?.open && (
|
||||
<AnnouncementsSidebar
|
||||
open={announcementsPanel.open}
|
||||
|
|
@ -307,18 +311,16 @@ export function LayoutShell({
|
|||
<InboxSidebar
|
||||
open={inbox.isOpen}
|
||||
onOpenChange={inbox.onOpenChange}
|
||||
mentions={inbox.mentions}
|
||||
comments={inbox.comments}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
markAsRead={inbox.markAsRead}
|
||||
markAllAsRead={inbox.markAllAsRead}
|
||||
isDocked={inbox.isDocked}
|
||||
onDockedChange={inbox.onDockedChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<Header breadcrumb={breadcrumb} />
|
||||
<Header />
|
||||
|
||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
{children}
|
||||
|
|
@ -330,17 +332,23 @@ export function LayoutShell({
|
|||
<InboxSidebar
|
||||
open={inbox.isOpen}
|
||||
onOpenChange={inbox.onOpenChange}
|
||||
mentions={inbox.mentions}
|
||||
comments={inbox.comments}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
markAsRead={inbox.markAsRead}
|
||||
markAllAsRead={inbox.markAllAsRead}
|
||||
isDocked={false}
|
||||
onDockedChange={inbox.onDockedChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Announcements Sidebar - positioned absolutely on top of content */}
|
||||
{/* Documents Sidebar - slide-out panel */}
|
||||
{documentsPanel && (
|
||||
<DocumentsSidebar
|
||||
open={documentsPanel.open}
|
||||
onOpenChange={documentsPanel.onOpenChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Announcements Sidebar */}
|
||||
{announcementsPanel && (
|
||||
<AnnouncementsSidebar
|
||||
open={announcementsPanel.open}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -40,6 +40,7 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import {
|
||||
deleteThread,
|
||||
|
|
@ -85,6 +86,15 @@ export function AllPrivateChatsSidebar({
|
|||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||
|
||||
const pendingThreadIdRef = useRef<number | null>(null);
|
||||
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
||||
useCallback(() => {
|
||||
if (pendingThreadIdRef.current !== null) {
|
||||
setOpenDropdownId(pendingThreadIdRef.current);
|
||||
}
|
||||
}, [])
|
||||
);
|
||||
|
||||
const isSearchMode = !!debouncedSearchQuery.trim();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -357,7 +367,16 @@ export function AllPrivateChatsSidebar({
|
|||
{isMobile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleThreadClick(thread.id)}
|
||||
onClick={() => {
|
||||
if (wasLongPress()) return;
|
||||
handleThreadClick(thread.id);
|
||||
}}
|
||||
onTouchStart={() => {
|
||||
pendingThreadIdRef.current = thread.id;
|
||||
longPressHandlers.onTouchStart();
|
||||
}}
|
||||
onTouchEnd={longPressHandlers.onTouchEnd}
|
||||
onTouchMove={longPressHandlers.onTouchMove}
|
||||
disabled={isBusy}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||
>
|
||||
|
|
@ -396,7 +415,9 @@ export function AllPrivateChatsSidebar({
|
|||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 shrink-0",
|
||||
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
||||
isMobile
|
||||
? "opacity-0 pointer-events-none absolute"
|
||||
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
||||
"transition-opacity"
|
||||
)}
|
||||
disabled={isBusy}
|
||||
|
|
@ -435,10 +456,7 @@ export function AllPrivateChatsSidebar({
|
|||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteThread(thread.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("delete") || "Delete"}</span>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -496,7 +514,7 @@ export function AllPrivateChatsSidebar({
|
|||
/>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
onClick={() => setShowRenameDialog(false)}
|
||||
disabled={isRenaming}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -40,6 +40,7 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import {
|
||||
deleteThread,
|
||||
|
|
@ -85,6 +86,15 @@ export function AllSharedChatsSidebar({
|
|||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||
|
||||
const pendingThreadIdRef = useRef<number | null>(null);
|
||||
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
||||
useCallback(() => {
|
||||
if (pendingThreadIdRef.current !== null) {
|
||||
setOpenDropdownId(pendingThreadIdRef.current);
|
||||
}
|
||||
}, [])
|
||||
);
|
||||
|
||||
const isSearchMode = !!debouncedSearchQuery.trim();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -357,7 +367,16 @@ export function AllSharedChatsSidebar({
|
|||
{isMobile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleThreadClick(thread.id)}
|
||||
onClick={() => {
|
||||
if (wasLongPress()) return;
|
||||
handleThreadClick(thread.id);
|
||||
}}
|
||||
onTouchStart={() => {
|
||||
pendingThreadIdRef.current = thread.id;
|
||||
longPressHandlers.onTouchStart();
|
||||
}}
|
||||
onTouchEnd={longPressHandlers.onTouchEnd}
|
||||
onTouchMove={longPressHandlers.onTouchMove}
|
||||
disabled={isBusy}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||
>
|
||||
|
|
@ -396,7 +415,9 @@ export function AllSharedChatsSidebar({
|
|||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 shrink-0",
|
||||
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
||||
isMobile
|
||||
? "opacity-0 pointer-events-none absolute"
|
||||
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
||||
"transition-opacity"
|
||||
)}
|
||||
disabled={isBusy}
|
||||
|
|
@ -435,10 +456,7 @@ export function AllSharedChatsSidebar({
|
|||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteThread(thread.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("delete") || "Delete"}</span>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -496,7 +514,7 @@ export function AllSharedChatsSidebar({
|
|||
/>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
onClick={() => setShowRenameDialog(false)}
|
||||
disabled={isRenaming}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
|
||||
import { AnnouncementCard } from "@/components/announcements/AnnouncementCard";
|
||||
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
|
|
@ -72,4 +72,3 @@ export function AnnouncementsSidebar({
|
|||
</SidebarSlideOutPanel>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -17,6 +18,8 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatListItemProps {
|
||||
|
|
@ -39,12 +42,24 @@ export function ChatListItem({
|
|||
onDelete,
|
||||
}: ChatListItemProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const isMobile = useIsMobile();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
||||
useCallback(() => setDropdownOpen(true), [])
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (wasLongPress()) return;
|
||||
onClick?.();
|
||||
}, [onClick, wasLongPress]);
|
||||
|
||||
return (
|
||||
<div className="group/item relative w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onClick={handleClick}
|
||||
{...(isMobile ? longPressHandlers : {})}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
||||
"[&>span:last-child]:truncate",
|
||||
|
|
@ -57,9 +72,14 @@ export function ChatListItem({
|
|||
<span className="w-[calc(100%-3rem)] ">{name}</span>
|
||||
</button>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-100 md:opacity-0 md:group-hover/item:opacity-100 transition-opacity">
|
||||
<DropdownMenu>
|
||||
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-1 top-1/2 -translate-y-1/2 transition-opacity",
|
||||
isMobile ? "opacity-0 pointer-events-none" : "opacity-0 group-hover/item:opacity-100"
|
||||
)}
|
||||
>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
|
@ -105,7 +125,6 @@ export function ChatListItem({
|
|||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("delete")}</span>
|
||||
|
|
|
|||
211
surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
Normal file
211
surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
|
||||
import {
|
||||
DocumentsTableShell,
|
||||
type SortKey,
|
||||
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell";
|
||||
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||
import { useDocuments } from "@/hooks/use-documents";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||
|
||||
interface DocumentsSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) {
|
||||
const t = useTranslations("documents");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const params = useParams();
|
||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
|
||||
const [sortKey, setSortKey] = useState<SortKey>("created_at");
|
||||
const [sortDesc, setSortDesc] = useState(true);
|
||||
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
||||
|
||||
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
|
||||
const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]);
|
||||
|
||||
const handleToggleChatMention = useCallback(
|
||||
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
||||
if (isMentioned) {
|
||||
setSidebarDocs((prev) => prev.filter((d) => d.id !== doc.id));
|
||||
} else {
|
||||
setSidebarDocs((prev) => {
|
||||
if (prev.some((d) => d.id === doc.id)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
|
||||
];
|
||||
});
|
||||
}
|
||||
},
|
||||
[setSidebarDocs]
|
||||
);
|
||||
|
||||
const isSearchMode = !!debouncedSearch.trim();
|
||||
|
||||
const {
|
||||
documents: realtimeDocuments,
|
||||
typeCounts: realtimeTypeCounts,
|
||||
loading: realtimeLoading,
|
||||
loadingMore: realtimeLoadingMore,
|
||||
hasMore: realtimeHasMore,
|
||||
loadMore: realtimeLoadMore,
|
||||
error: realtimeError,
|
||||
} = useDocuments(searchSpaceId, activeTypes, sortKey, sortDesc ? "desc" : "asc");
|
||||
|
||||
const {
|
||||
documents: searchDocuments,
|
||||
loading: searchLoading,
|
||||
loadingMore: searchLoadingMore,
|
||||
hasMore: searchHasMore,
|
||||
loadMore: searchLoadMore,
|
||||
error: searchError,
|
||||
removeItems: searchRemoveItems,
|
||||
} = useDocumentSearch(searchSpaceId, debouncedSearch, activeTypes, isSearchMode && open);
|
||||
|
||||
const displayDocs = isSearchMode ? searchDocuments : realtimeDocuments;
|
||||
const loading = isSearchMode ? searchLoading : realtimeLoading;
|
||||
const error = isSearchMode ? searchError : !!realtimeError;
|
||||
const hasMore = isSearchMode ? searchHasMore : realtimeHasMore;
|
||||
const loadingMore = isSearchMode ? searchLoadingMore : realtimeLoadingMore;
|
||||
const onLoadMore = isSearchMode ? searchLoadMore : realtimeLoadMore;
|
||||
|
||||
const onToggleType = (type: DocumentTypeEnum, checked: boolean) => {
|
||||
setActiveTypes((prev) => {
|
||||
if (checked) {
|
||||
return prev.includes(type) ? prev : [...prev, type];
|
||||
}
|
||||
return prev.filter((t) => t !== type);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteDocument = useCallback(
|
||||
async (id: number): Promise<boolean> => {
|
||||
try {
|
||||
await deleteDocumentMutation({ id });
|
||||
toast.success(t("delete_success") || "Document deleted");
|
||||
setSidebarDocs((prev) => prev.filter((d) => d.id !== id));
|
||||
if (isSearchMode) {
|
||||
searchRemoveItems([id]);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Error deleting document:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[deleteDocumentMutation, isSearchMode, t, searchRemoveItems, setSidebarDocs]
|
||||
);
|
||||
|
||||
const sortKeyRef = useRef(sortKey);
|
||||
const sortDescRef = useRef(sortDesc);
|
||||
sortKeyRef.current = sortKey;
|
||||
sortDescRef.current = sortDesc;
|
||||
|
||||
const handleSortChange = useCallback((key: SortKey) => {
|
||||
const currentKey = sortKeyRef.current;
|
||||
const currentDesc = sortDescRef.current;
|
||||
|
||||
if (currentKey === key && currentDesc) {
|
||||
setSortKey("created_at");
|
||||
setSortDesc(true);
|
||||
} else if (currentKey === key) {
|
||||
setSortDesc(true);
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDesc(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
const documentsContent = (
|
||||
<>
|
||||
<div className="shrink-0 p-4 pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{tSidebar("close") || "Close"}</span>
|
||||
</Button>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold">{t("title") || "Documents"}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
|
||||
<div className="px-4 pb-2">
|
||||
<DocumentsFilters
|
||||
typeCounts={realtimeTypeCounts}
|
||||
onSearch={setSearch}
|
||||
searchValue={search}
|
||||
onToggleType={onToggleType}
|
||||
activeTypes={activeTypes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DocumentsTableShell
|
||||
documents={displayDocs}
|
||||
loading={!!loading}
|
||||
error={!!error}
|
||||
sortKey={sortKey}
|
||||
sortDesc={sortDesc}
|
||||
onSortChange={handleSortChange}
|
||||
deleteDocument={handleDeleteDocument}
|
||||
searchSpaceId={String(searchSpaceId)}
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={onLoadMore}
|
||||
isSearchMode={isSearchMode}
|
||||
mentionedDocIds={mentionedDocIds}
|
||||
onToggleChatMention={handleToggleChatMention}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarSlideOutPanel
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
ariaLabel={t("title") || "Documents"}
|
||||
width={isMobile ? undefined : 480}
|
||||
>
|
||||
{documentsContent}
|
||||
</SidebarSlideOutPanel>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
|
|
@ -49,6 +50,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|||
import {
|
||||
isCommentReplyMetadata,
|
||||
isConnectorIndexingMetadata,
|
||||
isDocumentProcessingMetadata,
|
||||
isNewMentionMetadata,
|
||||
isPageLimitExceededMetadata,
|
||||
} from "@/contracts/types/inbox.types";
|
||||
|
|
@ -60,9 +62,6 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||
|
||||
/**
|
||||
* Get initials from name or email for avatar fallback
|
||||
*/
|
||||
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
|
||||
if (name) {
|
||||
return name
|
||||
|
|
@ -79,9 +78,6 @@ function getInitials(name: string | null | undefined, email: string | null | und
|
|||
return "U";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
|
||||
*/
|
||||
function formatInboxCount(count: number): string {
|
||||
if (count <= 999) {
|
||||
return count.toString();
|
||||
|
|
@ -90,9 +86,6 @@ function formatInboxCount(count: number): string {
|
|||
return `${thousands}k+`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for connector type
|
||||
*/
|
||||
function getConnectorTypeDisplayName(connectorType: string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
GITHUB_CONNECTOR: "GitHub",
|
||||
|
|
@ -135,44 +128,36 @@ function getConnectorTypeDisplayName(connectorType: string): string {
|
|||
}
|
||||
|
||||
type InboxTab = "comments" | "status";
|
||||
type InboxFilter = "all" | "unread";
|
||||
type InboxFilter = "all" | "unread" | "errors";
|
||||
|
||||
// Tab-specific data source with independent pagination
|
||||
interface TabDataSource {
|
||||
items: InboxItem[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
loadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
loadMore?: () => void;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface InboxSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Mentions tab data source with independent pagination */
|
||||
mentions: TabDataSource;
|
||||
/** Status tab data source with independent pagination */
|
||||
comments: TabDataSource;
|
||||
status: TabDataSource;
|
||||
/** Combined unread count for mark all as read */
|
||||
totalUnreadCount: number;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
/** Whether the inbox is docked (permanent) or floating */
|
||||
isDocked?: boolean;
|
||||
/** Callback to toggle docked state */
|
||||
onDockedChange?: (docked: boolean) => void;
|
||||
}
|
||||
|
||||
export function InboxSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
mentions,
|
||||
comments,
|
||||
status,
|
||||
totalUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
onCloseMobileSidebar,
|
||||
isDocked = false,
|
||||
onDockedChange,
|
||||
|
|
@ -183,9 +168,7 @@ export function InboxSidebar({
|
|||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
||||
|
||||
// Comments collapsed state (desktop only, when docked)
|
||||
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
|
||||
// Target comment for navigation - also ensures comments panel is visible
|
||||
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
|
@ -193,11 +176,9 @@ export function InboxSidebar({
|
|||
const isSearchMode = !!debouncedSearch.trim();
|
||||
const [activeTab, setActiveTab] = useState<InboxTab>("comments");
|
||||
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// Dropdown state for filter menu (desktop only)
|
||||
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
||||
// Scroll shadow state for connector list
|
||||
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
|
|
@ -205,15 +186,12 @@ export function InboxSidebar({
|
|||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
// Drawer state for filter menu (mobile only)
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||
|
||||
// Prefetch trigger ref - placed on item near the end
|
||||
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Server-side search query (enabled only when user is typing a search)
|
||||
// Determines which notification types to search based on active tab
|
||||
// Server-side search query
|
||||
const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined;
|
||||
const { data: searchResponse, isLoading: isSearchLoading } = useQuery({
|
||||
queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab),
|
||||
|
|
@ -226,7 +204,7 @@ export function InboxSidebar({
|
|||
limit: 50,
|
||||
},
|
||||
}),
|
||||
staleTime: 30 * 1000, // 30 seconds (search results don't need to be super fresh)
|
||||
staleTime: 30 * 1000,
|
||||
enabled: isSearchMode && open,
|
||||
});
|
||||
|
||||
|
|
@ -244,129 +222,128 @@ export function InboxSidebar({
|
|||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
// Only lock body scroll on mobile when inbox is open
|
||||
useEffect(() => {
|
||||
if (!open || !isMobile) return;
|
||||
|
||||
// Store original overflow to restore on cleanup
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow;
|
||||
};
|
||||
}, [open, isMobile]);
|
||||
|
||||
// Reset connector filter when switching away from status tab
|
||||
useEffect(() => {
|
||||
if (activeTab !== "status") {
|
||||
setSelectedConnector(null);
|
||||
setSelectedSource(null);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Each tab uses its own data source for independent pagination
|
||||
// Comments tab: uses mentions data source (fetches only mention/reply types from server)
|
||||
const commentsItems = mentions.items;
|
||||
// Active tab's data source — fully independent loading, pagination, and counts
|
||||
const activeSource = activeTab === "comments" ? comments : status;
|
||||
|
||||
// Status tab: filters status data source (fetches all types) to status-specific types
|
||||
const statusItems = useMemo(
|
||||
() =>
|
||||
status.items.filter(
|
||||
(item) =>
|
||||
item.type === "connector_indexing" ||
|
||||
item.type === "document_processing" ||
|
||||
item.type === "page_limit_exceeded" ||
|
||||
item.type === "connector_deletion"
|
||||
),
|
||||
[status.items]
|
||||
// Fetch source types for the status tab filter
|
||||
const { data: sourceTypesData } = useQuery({
|
||||
queryKey: cacheKeys.notifications.sourceTypes(searchSpaceId),
|
||||
queryFn: () => notificationsApiService.getSourceTypes(searchSpaceId ?? undefined),
|
||||
staleTime: 60 * 1000,
|
||||
enabled: open && activeTab === "status",
|
||||
});
|
||||
|
||||
const statusSourceOptions = useMemo(() => {
|
||||
if (!sourceTypesData?.sources) return [];
|
||||
|
||||
return sourceTypesData.sources.map((source) => ({
|
||||
key: source.key,
|
||||
type: source.type,
|
||||
category: source.category,
|
||||
displayName:
|
||||
source.category === "connector"
|
||||
? getConnectorTypeDisplayName(source.type)
|
||||
: getDocumentTypeLabel(source.type),
|
||||
}));
|
||||
}, [sourceTypesData]);
|
||||
|
||||
// Client-side filter: source type
|
||||
const matchesSourceFilter = useCallback(
|
||||
(item: InboxItem): boolean => {
|
||||
if (!selectedSource) return true;
|
||||
if (selectedSource.startsWith("connector:")) {
|
||||
const connectorType = selectedSource.slice("connector:".length);
|
||||
return (
|
||||
item.type === "connector_indexing" &&
|
||||
isConnectorIndexingMetadata(item.metadata) &&
|
||||
item.metadata.connector_type === connectorType
|
||||
);
|
||||
}
|
||||
if (selectedSource.startsWith("doctype:")) {
|
||||
const docType = selectedSource.slice("doctype:".length);
|
||||
return (
|
||||
item.type === "document_processing" &&
|
||||
isDocumentProcessingMetadata(item.metadata) &&
|
||||
item.metadata.document_type === docType
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[selectedSource]
|
||||
);
|
||||
|
||||
// Pagination switches based on active tab
|
||||
const loading = activeTab === "comments" ? mentions.loading : status.loading;
|
||||
const loadingMore =
|
||||
activeTab === "comments" ? (mentions.loadingMore ?? false) : (status.loadingMore ?? false);
|
||||
const hasMore =
|
||||
activeTab === "comments" ? (mentions.hasMore ?? false) : (status.hasMore ?? false);
|
||||
const loadMore = activeTab === "comments" ? mentions.loadMore : status.loadMore;
|
||||
// Client-side filter: unread / errors
|
||||
const matchesActiveFilter = useCallback(
|
||||
(item: InboxItem): boolean => {
|
||||
if (activeFilter === "unread") return !item.read;
|
||||
if (activeFilter === "errors") {
|
||||
if (item.type === "page_limit_exceeded") return true;
|
||||
const meta = item.metadata as Record<string, unknown> | undefined;
|
||||
return typeof meta?.status === "string" && meta.status === "failed";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[activeFilter]
|
||||
);
|
||||
|
||||
// Get unique connector types from status items for filtering
|
||||
const uniqueConnectorTypes = useMemo(() => {
|
||||
const connectorTypes = new Set<string>();
|
||||
|
||||
statusItems
|
||||
.filter((item) => item.type === "connector_indexing")
|
||||
.forEach((item) => {
|
||||
// Use type guard for safe metadata access
|
||||
if (isConnectorIndexingMetadata(item.metadata)) {
|
||||
connectorTypes.add(item.metadata.connector_type);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(connectorTypes).map((type) => ({
|
||||
type,
|
||||
displayName: getConnectorTypeDisplayName(type),
|
||||
}));
|
||||
}, [statusItems]);
|
||||
|
||||
// Get items for current tab
|
||||
const displayItems = activeTab === "comments" ? commentsItems : statusItems;
|
||||
|
||||
// Filter items based on filter type, connector filter, and search mode
|
||||
// When searching: use server-side API results (searches ALL notifications)
|
||||
// When not searching: use Electric real-time items (fast, local)
|
||||
// Two data paths: search mode (API) or default (per-tab data source)
|
||||
const filteredItems = useMemo(() => {
|
||||
// In search mode, use API results
|
||||
let items: InboxItem[] = isSearchMode ? (searchResponse?.items ?? []) : displayItems;
|
||||
let tabItems: InboxItem[];
|
||||
|
||||
// For status tab search results, filter to status-specific types
|
||||
if (isSearchMode && activeTab === "status") {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.type === "connector_indexing" ||
|
||||
item.type === "document_processing" ||
|
||||
item.type === "page_limit_exceeded" ||
|
||||
item.type === "connector_deletion"
|
||||
);
|
||||
if (isSearchMode) {
|
||||
tabItems = searchResponse?.items ?? [];
|
||||
} else {
|
||||
tabItems = activeSource.items;
|
||||
}
|
||||
|
||||
// Apply read/unread filter
|
||||
if (activeFilter === "unread") {
|
||||
items = items.filter((item) => !item.read);
|
||||
let result = tabItems;
|
||||
if (activeFilter !== "all") {
|
||||
result = result.filter(matchesActiveFilter);
|
||||
}
|
||||
if (activeTab === "status" && selectedSource) {
|
||||
result = result.filter(matchesSourceFilter);
|
||||
}
|
||||
|
||||
// Apply connector filter (only for status tab)
|
||||
if (activeTab === "status" && selectedConnector) {
|
||||
items = items.filter((item) => {
|
||||
if (item.type === "connector_indexing") {
|
||||
// Use type guard for safe metadata access
|
||||
if (isConnectorIndexingMetadata(item.metadata)) {
|
||||
return item.metadata.connector_type === selectedConnector;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false; // Hide document_processing when a specific connector is selected
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [
|
||||
isSearchMode,
|
||||
searchResponse,
|
||||
activeSource.items,
|
||||
activeTab,
|
||||
activeFilter,
|
||||
selectedSource,
|
||||
matchesActiveFilter,
|
||||
matchesSourceFilter,
|
||||
]);
|
||||
|
||||
return items;
|
||||
}, [displayItems, searchResponse, isSearchMode, activeFilter, activeTab, selectedConnector]);
|
||||
|
||||
// Intersection Observer for infinite scroll with prefetching
|
||||
// Re-runs when active tab changes so each tab gets its own pagination
|
||||
// Disabled during server-side search (search results are not paginated via infinite scroll)
|
||||
// Infinite scroll — uses active tab's pagination
|
||||
useEffect(() => {
|
||||
if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return;
|
||||
if (!activeSource.hasMore || activeSource.loadingMore || !open || isSearchMode) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// When trigger element is visible, load more
|
||||
if (entries[0]?.isIntersecting) {
|
||||
loadMore();
|
||||
activeSource.loadMore();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null, // viewport
|
||||
rootMargin: "100px", // Start loading 100px before visible
|
||||
root: null,
|
||||
rootMargin: "100px",
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
|
@ -376,17 +353,13 @@ export function InboxSidebar({
|
|||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]);
|
||||
|
||||
// Unread counts from server-side accurate totals (passed via props)
|
||||
const unreadCommentsCount = mentions.unreadCount;
|
||||
const unreadStatusCount = status.unreadCount;
|
||||
}, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, open, isSearchMode]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
async (item: InboxItem) => {
|
||||
if (!item.read) {
|
||||
setMarkingAsReadId(item.id);
|
||||
await markAsRead(item.id);
|
||||
await activeSource.markAsRead(item.id);
|
||||
setMarkingAsReadId(null);
|
||||
}
|
||||
|
||||
|
|
@ -427,7 +400,6 @@ export function InboxSidebar({
|
|||
}
|
||||
}
|
||||
} else if (item.type === "page_limit_exceeded") {
|
||||
// Navigate to the upgrade/more-pages page
|
||||
if (isPageLimitExceededMetadata(item.metadata)) {
|
||||
const actionUrl = item.metadata.action_url;
|
||||
if (actionUrl) {
|
||||
|
|
@ -438,12 +410,12 @@ export function InboxSidebar({
|
|||
}
|
||||
}
|
||||
},
|
||||
[markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
|
||||
[activeSource.markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
|
||||
);
|
||||
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
await markAllAsRead();
|
||||
}, [markAllAsRead]);
|
||||
await Promise.all([comments.markAllAsRead(), status.markAllAsRead()]);
|
||||
}, [comments.markAllAsRead, status.markAllAsRead]);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
|
|
@ -469,7 +441,6 @@ export function InboxSidebar({
|
|||
};
|
||||
|
||||
const getStatusIcon = (item: InboxItem) => {
|
||||
// For mentions and comment replies, show the author's avatar
|
||||
if (item.type === "new_mention" || item.type === "comment_reply") {
|
||||
const metadata =
|
||||
item.type === "new_mention"
|
||||
|
|
@ -501,7 +472,6 @@ export function InboxSidebar({
|
|||
);
|
||||
}
|
||||
|
||||
// For page limit exceeded, show a warning icon with amber/orange color
|
||||
if (item.type === "page_limit_exceeded") {
|
||||
return (
|
||||
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10">
|
||||
|
|
@ -510,8 +480,6 @@ export function InboxSidebar({
|
|||
);
|
||||
}
|
||||
|
||||
// For status items (connector/document), show status icons
|
||||
// Safely access status from metadata
|
||||
const metadata = item.metadata as Record<string, unknown>;
|
||||
const status = typeof metadata?.status === "string" ? metadata.status : undefined;
|
||||
|
||||
|
|
@ -558,13 +526,13 @@ export function InboxSidebar({
|
|||
|
||||
if (!mounted) return null;
|
||||
|
||||
// Shared content component for both docked and floating modes
|
||||
const isLoading = isSearchMode ? isSearchLoading : activeSource.loading;
|
||||
|
||||
const inboxContent = (
|
||||
<>
|
||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Back button - mobile only */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -579,7 +547,6 @@ export function InboxSidebar({
|
|||
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Mobile: Button that opens bottom drawer */}
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Button
|
||||
|
|
@ -605,7 +572,6 @@ export function InboxSidebar({
|
|||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Filter section */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||
{t("filter") || "Filter"}
|
||||
|
|
@ -649,56 +615,74 @@ export function InboxSidebar({
|
|||
</span>
|
||||
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
{activeTab === "status" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFilter("errors");
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||
activeFilter === "errors"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{t("errors_only") || "Errors only"}</span>
|
||||
</span>
|
||||
{activeFilter === "errors" && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Connectors section - only for status tab */}
|
||||
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
||||
{activeTab === "status" && statusSourceOptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||
{t("connectors") || "Connectors"}
|
||||
{t("sources") || "Sources"}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedConnector(null);
|
||||
setSelectedSource(null);
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||
selectedConnector === null
|
||||
selectedSource === null
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span>{t("all_connectors") || "All connectors"}</span>
|
||||
<span>{t("all_sources") || "All sources"}</span>
|
||||
</span>
|
||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||
{selectedSource === null && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
{statusSourceOptions.map((source) => (
|
||||
<button
|
||||
key={connector.type}
|
||||
key={source.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedConnector(connector.type);
|
||||
setSelectedSource(source.key);
|
||||
setFilterDrawerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||
selectedConnector === connector.type
|
||||
selectedSource === source.key
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
{getConnectorIcon(source.type, "h-4 w-4")}
|
||||
<span>{source.displayName}</span>
|
||||
</span>
|
||||
{selectedConnector === connector.type && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
{selectedSource === source.key && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -709,7 +693,6 @@ export function InboxSidebar({
|
|||
</Drawer>
|
||||
</>
|
||||
) : (
|
||||
/* Desktop: Dropdown menu */
|
||||
<DropdownMenu
|
||||
open={openDropdown === "filter"}
|
||||
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
|
||||
|
|
@ -727,7 +710,10 @@ export function InboxSidebar({
|
|||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn("z-80 select-none", activeTab === "status" ? "w-52" : "w-44")}
|
||||
className={cn(
|
||||
"z-80 select-none max-h-[60vh] overflow-hidden flex flex-col",
|
||||
activeTab === "status" ? "w-52" : "w-44"
|
||||
)}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
|
||||
{t("filter") || "Filter"}
|
||||
|
|
@ -752,13 +738,25 @@ export function InboxSidebar({
|
|||
</span>
|
||||
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
||||
{activeTab === "status" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setActiveFilter("errors")}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{t("errors_only") || "Errors only"}</span>
|
||||
</span>
|
||||
{activeFilter === "errors" && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{activeTab === "status" && statusSourceOptions.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
|
||||
{t("connectors") || "Connectors"}
|
||||
{t("sources") || "Sources"}
|
||||
</DropdownMenuLabel>
|
||||
<div
|
||||
className="relative max-h-[30vh] overflow-y-auto -mb-1"
|
||||
className="relative max-h-[30vh] overflow-y-auto overflow-x-hidden -mb-1"
|
||||
onScroll={handleConnectorScroll}
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
|
|
@ -766,26 +764,26 @@ export function InboxSidebar({
|
|||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSelectedConnector(null)}
|
||||
onClick={() => setSelectedSource(null)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span>{t("all_connectors") || "All connectors"}</span>
|
||||
<span>{t("all_sources") || "All sources"}</span>
|
||||
</span>
|
||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||
{selectedSource === null && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
{statusSourceOptions.map((source) => (
|
||||
<DropdownMenuItem
|
||||
key={connector.type}
|
||||
onClick={() => setSelectedConnector(connector.type)}
|
||||
key={source.key}
|
||||
onClick={() => setSelectedSource(source.key)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
{getConnectorIcon(source.type, "h-4 w-4")}
|
||||
<span>{source.displayName}</span>
|
||||
</span>
|
||||
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
|
||||
{selectedSource === source.key && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -824,7 +822,6 @@ export function InboxSidebar({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Dock/Undock button - desktop only */}
|
||||
{!isMobile && onDockedChange && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -834,12 +831,10 @@ export function InboxSidebar({
|
|||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => {
|
||||
if (isDocked) {
|
||||
// Collapse: show comments immediately, then close inbox
|
||||
setCommentsCollapsed(false);
|
||||
onDockedChange(false);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
// Expand: hide comments immediately
|
||||
setCommentsCollapsed(true);
|
||||
onDockedChange(true);
|
||||
}
|
||||
|
|
@ -886,7 +881,13 @@ export function InboxSidebar({
|
|||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as InboxTab)}
|
||||
onValueChange={(value) => {
|
||||
const tab = value as InboxTab;
|
||||
setActiveTab(tab);
|
||||
if (tab !== "status" && activeFilter === "errors") {
|
||||
setActiveFilter("all");
|
||||
}
|
||||
}}
|
||||
className="shrink-0 mx-4"
|
||||
>
|
||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||
|
|
@ -898,7 +899,7 @@ export function InboxSidebar({
|
|||
<MessageSquare className="h-4 w-4" />
|
||||
<span>{t("comments") || "Comments"}</span>
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||
{formatInboxCount(unreadCommentsCount)}
|
||||
{formatInboxCount(comments.unreadCount)}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
|
|
@ -910,7 +911,7 @@ export function InboxSidebar({
|
|||
<History className="h-4 w-4" />
|
||||
<span>{t("status") || "Status"}</span>
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||
{formatInboxCount(unreadStatusCount)}
|
||||
{formatInboxCount(status.unreadCount)}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
|
|
@ -918,11 +919,10 @@ export function InboxSidebar({
|
|||
</Tabs>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{(isSearchMode ? isSearchLoading : loading) ? (
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{activeTab === "comments"
|
||||
? /* Comments skeleton: avatar + two-line text + time */
|
||||
[85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
|
||||
? [85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
|
||||
<div
|
||||
key={`skeleton-comment-${i}`}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
||||
|
|
@ -935,8 +935,7 @@ export function InboxSidebar({
|
|||
<Skeleton className="h-3 w-6 shrink-0 rounded" />
|
||||
</div>
|
||||
))
|
||||
: /* Status skeleton: status icon circle + two-line text + time */
|
||||
[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
||||
: [75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
||||
<div
|
||||
key={`skeleton-status-${i}`}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
|
||||
|
|
@ -957,9 +956,8 @@ export function InboxSidebar({
|
|||
<div className="space-y-2">
|
||||
{filteredItems.map((item, index) => {
|
||||
const isMarkingAsRead = markingAsReadId === item.id;
|
||||
// Place prefetch trigger on 5th item from end (only when not searching)
|
||||
const isPrefetchTrigger =
|
||||
!isSearchMode && hasMore && index === filteredItems.length - 5;
|
||||
!isSearchMode && activeSource.hasMore && index === filteredItems.length - 5;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -1028,7 +1026,6 @@ export function InboxSidebar({
|
|||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Time and unread dot - fixed width to prevent content shift */}
|
||||
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatTime(item.created_at)}
|
||||
|
|
@ -1038,12 +1035,10 @@ export function InboxSidebar({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Fallback trigger at the very end if less than 5 items and not searching */}
|
||||
{!isSearchMode && filteredItems.length < 5 && hasMore && (
|
||||
{!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && (
|
||||
<div ref={prefetchTriggerRef} className="h-1" />
|
||||
)}
|
||||
{/* Loading more skeletons at the bottom during infinite scroll */}
|
||||
{loadingMore &&
|
||||
{activeSource.loadingMore &&
|
||||
(activeTab === "comments"
|
||||
? [80, 60, 90].map((titleWidth, i) => (
|
||||
<div
|
||||
|
|
@ -1100,11 +1095,10 @@ export function InboxSidebar({
|
|||
</>
|
||||
);
|
||||
|
||||
// DOCKED MODE: Render as a static flex child (no animation, no click-away)
|
||||
if (isDocked && open && !isMobile) {
|
||||
return (
|
||||
<aside
|
||||
className="h-full w-[360px] shrink-0 bg-background flex flex-col border-r"
|
||||
className="h-full w-[360px] shrink-0 bg-sidebar text-sidebar-foreground flex flex-col border-r"
|
||||
aria-label={t("inbox") || "Inbox"}
|
||||
>
|
||||
{inboxContent}
|
||||
|
|
@ -1112,7 +1106,6 @@ export function InboxSidebar({
|
|||
);
|
||||
}
|
||||
|
||||
// FLOATING MODE: Render with animation and click-away layer
|
||||
return (
|
||||
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
|
||||
{inboxContent}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { NavItem } from "../../types/layout.types";
|
||||
|
|
@ -39,11 +40,15 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
{...joyrideAttr}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.badge && (
|
||||
{item.showSpinner ? (
|
||||
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center h-[14px] w-[14px] rounded-full bg-primary/15">
|
||||
<Spinner size="xs" className="text-primary" />
|
||||
</span>
|
||||
) : item.badge ? (
|
||||
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full bg-red-500 text-white text-[9px] font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<span className="sr-only">{item.title}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -67,7 +72,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
)}
|
||||
{...joyrideAttr}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{item.showSpinner ? (
|
||||
<Spinner size="sm" className="shrink-0 text-primary" />
|
||||
) : (
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate">{item.title}</span>
|
||||
{item.badge && (
|
||||
<span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { PanelLeft, PanelLeftClose } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
|
||||
|
||||
|
|
@ -18,7 +19,7 @@ export function SidebarCollapseButton({
|
|||
disableTooltip = false,
|
||||
}: SidebarCollapseButtonProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const { shortcut } = usePlatformShortcut();
|
||||
const { shortcutKeys } = usePlatformShortcut();
|
||||
|
||||
const button = (
|
||||
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
|
||||
|
|
@ -35,9 +36,10 @@ export function SidebarCollapseButton({
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
|
||||
{isCollapsed
|
||||
? `${t("expand_sidebar")} ${shortcut("Mod", "\\")}`
|
||||
: `${t("collapse_sidebar")} ${shortcut("Mod", "\\")}`}
|
||||
<span className="flex items-center">
|
||||
{isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
|
||||
<ShortcutKbd keys={shortcutKeys("Mod", "\\")} />
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -56,7 +55,6 @@ export function SidebarHeader({
|
|||
<UserPen className="h-4 w-4" />
|
||||
{t("manage_members")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onSettings}>
|
||||
<Settings className="h-4 w-4" />
|
||||
{t("search_space_settings")}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function SidebarSlideOutPanel({
|
|||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
"h-full w-full bg-background flex flex-col pointer-events-auto select-none",
|
||||
"h-full w-full bg-sidebar text-sidebar-foreground flex flex-col pointer-events-auto select-none",
|
||||
"sm:border-r sm:shadow-xl"
|
||||
)}
|
||||
role="dialog"
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ export function SidebarUserProfile({
|
|||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||
|
||||
<DropdownMenuItem onClick={onUserSettings}>
|
||||
<Settings className="h-4 w-4" />
|
||||
|
|
@ -256,7 +256,7 @@ export function SidebarUserProfile({
|
|||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||
|
||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||
{isLoggingOut ? (
|
||||
|
|
@ -310,7 +310,7 @@ export function SidebarUserProfile({
|
|||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||
|
||||
<DropdownMenuItem onClick={onUserSettings}>
|
||||
<Settings className="h-4 w-4" />
|
||||
|
|
@ -378,7 +378,7 @@ export function SidebarUserProfile({
|
|||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator className="dark:bg-neutral-700" />
|
||||
|
||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||
{isLoggingOut ? <Spinner size="sm" className="mr-2" /> : <LogOut className="h-4 w-4" />}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
|
|||
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
||||
export { AnnouncementsSidebar } from "./AnnouncementsSidebar";
|
||||
export { ChatListItem } from "./ChatListItem";
|
||||
export { DocumentsSidebar } from "./DocumentsSidebar";
|
||||
export { InboxSidebar } from "./InboxSidebar";
|
||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||
export { NavSection } from "./NavSection";
|
||||
|
|
|
|||
|
|
@ -7,38 +7,39 @@ import type {
|
|||
ImageGenerationConfig,
|
||||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { ImageConfigSidebar } from "./image-config-sidebar";
|
||||
import { ModelConfigSidebar } from "./model-config-sidebar";
|
||||
import { ImageConfigDialog } from "./image-config-dialog";
|
||||
import { ModelConfigDialog } from "./model-config-dialog";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
searchSpaceId: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
||||
// LLM config sidebar state
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
|
||||
// LLM config dialog state
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [selectedConfig, setSelectedConfig] = useState<
|
||||
NewLLMConfigPublic | GlobalNewLLMConfig | null
|
||||
>(null);
|
||||
const [isGlobal, setIsGlobal] = useState(false);
|
||||
const [sidebarMode, setSidebarMode] = useState<"create" | "edit" | "view">("view");
|
||||
const [dialogMode, setDialogMode] = useState<"create" | "edit" | "view">("view");
|
||||
|
||||
// Image config sidebar state
|
||||
const [imageSidebarOpen, setImageSidebarOpen] = useState(false);
|
||||
// Image config dialog state
|
||||
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
||||
const [selectedImageConfig, setSelectedImageConfig] = useState<
|
||||
ImageGenerationConfig | GlobalImageGenConfig | null
|
||||
>(null);
|
||||
const [isImageGlobal, setIsImageGlobal] = useState(false);
|
||||
const [imageSidebarMode, setImageSidebarMode] = useState<"create" | "edit" | "view">("view");
|
||||
const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view");
|
||||
|
||||
// LLM handlers
|
||||
const handleEditLLMConfig = useCallback(
|
||||
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
|
||||
setSelectedConfig(config);
|
||||
setIsGlobal(global);
|
||||
setSidebarMode(global ? "view" : "edit");
|
||||
setSidebarOpen(true);
|
||||
setDialogMode(global ? "view" : "edit");
|
||||
setDialogOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
|
@ -46,12 +47,12 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
|||
const handleAddNewLLM = useCallback(() => {
|
||||
setSelectedConfig(null);
|
||||
setIsGlobal(false);
|
||||
setSidebarMode("create");
|
||||
setSidebarOpen(true);
|
||||
setDialogMode("create");
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSidebarClose = useCallback((open: boolean) => {
|
||||
setSidebarOpen(open);
|
||||
const handleDialogClose = useCallback((open: boolean) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) setSelectedConfig(null);
|
||||
}, []);
|
||||
|
||||
|
|
@ -59,22 +60,22 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
|||
const handleAddImageModel = useCallback(() => {
|
||||
setSelectedImageConfig(null);
|
||||
setIsImageGlobal(false);
|
||||
setImageSidebarMode("create");
|
||||
setImageSidebarOpen(true);
|
||||
setImageDialogMode("create");
|
||||
setImageDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditImageConfig = useCallback(
|
||||
(config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => {
|
||||
setSelectedImageConfig(config);
|
||||
setIsImageGlobal(global);
|
||||
setImageSidebarMode(global ? "view" : "edit");
|
||||
setImageSidebarOpen(true);
|
||||
setImageDialogMode(global ? "view" : "edit");
|
||||
setImageDialogOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleImageSidebarClose = useCallback((open: boolean) => {
|
||||
setImageSidebarOpen(open);
|
||||
const handleImageDialogClose = useCallback((open: boolean) => {
|
||||
setImageDialogOpen(open);
|
||||
if (!open) setSelectedImageConfig(null);
|
||||
}, []);
|
||||
|
||||
|
|
@ -85,22 +86,23 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
|||
onAddNewLLM={handleAddNewLLM}
|
||||
onEditImage={handleEditImageConfig}
|
||||
onAddNewImage={handleAddImageModel}
|
||||
className={className}
|
||||
/>
|
||||
<ModelConfigSidebar
|
||||
open={sidebarOpen}
|
||||
onOpenChange={handleSidebarClose}
|
||||
<ModelConfigDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={handleDialogClose}
|
||||
config={selectedConfig}
|
||||
isGlobal={isGlobal}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={sidebarMode}
|
||||
mode={dialogMode}
|
||||
/>
|
||||
<ImageConfigSidebar
|
||||
open={imageSidebarOpen}
|
||||
onOpenChange={handleImageSidebarClose}
|
||||
<ImageConfigDialog
|
||||
open={imageDialogOpen}
|
||||
onOpenChange={handleImageDialogClose}
|
||||
config={selectedImageConfig}
|
||||
isGlobal={isImageGlobal}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={imageSidebarMode}
|
||||
mode={imageDialogMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -72,12 +72,15 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
// Query to check if thread has public snapshots
|
||||
const { data: snapshotsData } = useQuery({
|
||||
queryKey: ["thread-snapshots", thread?.id],
|
||||
queryFn: () => chatThreadsApiService.listPublicChatSnapshots({ thread_id: thread!.id }),
|
||||
queryFn: () => {
|
||||
const id = thread?.id;
|
||||
if (id == null) throw new Error("Missing thread id");
|
||||
return chatThreadsApiService.listPublicChatSnapshots({ thread_id: id });
|
||||
},
|
||||
enabled: !!thread?.id,
|
||||
staleTime: 30000, // Cache for 30 seconds
|
||||
});
|
||||
const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0;
|
||||
const snapshotCount = snapshotsData?.snapshots?.length ?? 0;
|
||||
|
||||
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
||||
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
||||
|
|
@ -152,11 +155,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
<Earth className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{snapshotCount === 1
|
||||
? "This chat has a public link"
|
||||
: `This chat has ${snapshotCount} public links`}
|
||||
</TooltipContent>
|
||||
<TooltipContent>Manage public links</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
|
|
@ -167,7 +166,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0"
|
||||
className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0 select-none"
|
||||
>
|
||||
<CurrentIcon className="h-4 w-4" />
|
||||
<span className="hidden md:inline text-sm">{buttonLabel}</span>
|
||||
|
|
@ -178,12 +177,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
|
||||
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="p-1.5 space-y-1 select-none">
|
||||
<div className="p-1.5 space-y-1">
|
||||
{/* Visibility Options */}
|
||||
{visibilityOptions.map((option) => {
|
||||
const isSelected = currentVisibility === option.value;
|
||||
|
|
@ -196,27 +195,32 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
onClick={() => handleVisibilityChange(option.value)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||
"hover:bg-accent/50 cursor-pointer",
|
||||
"hover:bg-accent/50 dark:hover:bg-white/10 cursor-pointer",
|
||||
"focus:outline-none",
|
||||
isSelected && "bg-accent/80"
|
||||
isSelected && "bg-accent/80 dark:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"size-7 rounded-md shrink-0 grid place-items-center",
|
||||
isSelected ? "bg-primary/10" : "bg-muted"
|
||||
isSelected ? "bg-primary/10 dark:bg-white/10" : "bg-muted dark:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-4 block",
|
||||
isSelected ? "text-primary" : "text-muted-foreground"
|
||||
isSelected ? "text-primary dark:text-white" : "text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("text-sm font-medium", isSelected && "text-primary")}>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isSelected && "text-primary dark:text-white"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -231,7 +235,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
{canCreatePublicLink && (
|
||||
<>
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border my-1" />
|
||||
<div className="border-t border-border dark:border-white/5 my-1" />
|
||||
|
||||
{/* Public Link Option */}
|
||||
<button
|
||||
|
|
@ -240,12 +244,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
disabled={isCreatingSnapshot}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||
"hover:bg-accent/50 cursor-pointer",
|
||||
"hover:bg-accent/50 dark:hover:bg-white/10 cursor-pointer",
|
||||
"focus:outline-none",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
|
||||
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted dark:bg-white/5">
|
||||
<Earth className="size-4 block text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
|
|
|
|||
|
|
@ -1,19 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
Key,
|
||||
Shuffle,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { AlertCircle, Check, ChevronsUpDown, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -48,10 +38,11 @@ import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-g
|
|||
import type {
|
||||
GlobalImageGenConfig,
|
||||
ImageGenerationConfig,
|
||||
ImageGenProvider,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImageConfigSidebarProps {
|
||||
interface ImageConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: ImageGenerationConfig | GlobalImageGenConfig | null;
|
||||
|
|
@ -70,24 +61,25 @@ const INITIAL_FORM = {
|
|||
api_version: "",
|
||||
};
|
||||
|
||||
export function ImageConfigSidebar({
|
||||
export function ImageConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
isGlobal,
|
||||
searchSpaceId,
|
||||
mode,
|
||||
}: ImageConfigSidebarProps) {
|
||||
}: ImageConfigDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [formData, setFormData] = useState(INITIAL_FORM);
|
||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Reset form when opening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (mode === "edit" && config && !isGlobal) {
|
||||
|
|
@ -103,15 +95,14 @@ export function ImageConfigSidebar({
|
|||
} else if (mode === "create") {
|
||||
setFormData(INITIAL_FORM);
|
||||
}
|
||||
setScrollPos("top");
|
||||
}
|
||||
}, [open, mode, config, isGlobal]);
|
||||
|
||||
// Mutations
|
||||
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
|
||||
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
// Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) onOpenChange(false);
|
||||
|
|
@ -120,6 +111,13 @@ export function ImageConfigSidebar({
|
|||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
|
||||
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
|
||||
|
||||
const suggestedModels = useMemo(() => {
|
||||
|
|
@ -134,13 +132,20 @@ export function ImageConfigSidebar({
|
|||
return "Edit Image Model";
|
||||
};
|
||||
|
||||
const getSubtitle = () => {
|
||||
if (mode === "create") return "Set up a new image generation provider";
|
||||
if (isAutoMode) return "Automatically routes requests across providers";
|
||||
if (isGlobal) return "Read-only global configuration";
|
||||
return "Update your image model settings";
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const result = await createConfig({
|
||||
name: formData.name,
|
||||
provider: formData.provider,
|
||||
provider: formData.provider as ImageGenProvider,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
|
|
@ -148,7 +153,6 @@ export function ImageConfigSidebar({
|
|||
description: formData.description || undefined,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
// Set as active image model
|
||||
if (result?.id) {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
|
|
@ -163,7 +167,7 @@ export function ImageConfigSidebar({
|
|||
data: {
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
provider: formData.provider,
|
||||
provider: formData.provider as ImageGenProvider,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
|
|
@ -214,126 +218,96 @@ export function ImageConfigSidebar({
|
|||
|
||||
if (!mounted) return null;
|
||||
|
||||
const sidebarContent = (
|
||||
const dialogContent = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm"
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<motion.div
|
||||
initial={{ x: "100%", opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: "100%", opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
||||
className={cn(
|
||||
"fixed right-0 top-0 z-50 h-full w-full sm:w-[480px] lg:w-[540px]",
|
||||
"bg-background border-l border-border/50 shadow-2xl",
|
||||
"flex flex-col"
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
"flex items-center justify-between px-6 py-4 border-b border-border/50",
|
||||
isAutoMode
|
||||
? "bg-gradient-to-r from-violet-500/10 to-purple-500/10"
|
||||
: "bg-gradient-to-r from-teal-500/10 to-cyan-500/10"
|
||||
"relative w-full max-w-lg h-[85vh]",
|
||||
"rounded-xl bg-background shadow-2xl ring-1 ring-border/50",
|
||||
"dark:bg-neutral-900 dark:ring-white/5",
|
||||
"flex flex-col overflow-hidden"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center size-10 rounded-xl",
|
||||
isAutoMode
|
||||
? "bg-gradient-to-br from-violet-500 to-purple-600"
|
||||
: "bg-gradient-to-br from-teal-500 to-cyan-600"
|
||||
)}
|
||||
>
|
||||
{isAutoMode ? (
|
||||
<Shuffle className="size-5 text-white" />
|
||||
) : (
|
||||
<ImageIcon className="size-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold">{getTitle()}</h2>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{isAutoMode ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
||||
>
|
||||
<Zap className="size-3" />
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4">
|
||||
<div className="space-y-1 pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||
{isAutoMode && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Recommended
|
||||
</Badge>
|
||||
) : isGlobal ? (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Globe className="size-3" />
|
||||
)}
|
||||
{isGlobal && !isAutoMode && mode !== "create" && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Global
|
||||
</Badge>
|
||||
) : null}
|
||||
{config && !isAutoMode && (
|
||||
<span className="text-xs text-muted-foreground">{config.model_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||
{config && !isAutoMode && mode !== "create" && (
|
||||
<p className="text-xs font-mono text-muted-foreground/70">
|
||||
{config.model_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-4 top-4 h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 w-8 rounded-full"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6">
|
||||
{/* Auto mode */}
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto px-6 py-5"
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
{isAutoMode && (
|
||||
<>
|
||||
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5">
|
||||
<Shuffle className="size-4 text-violet-500" />
|
||||
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
||||
Auto mode distributes image generation requests across all configured
|
||||
providers for optimal performance and rate limit protection.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex gap-3 pt-4 border-t border-border/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Loading..." : "Use Auto Mode"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
<Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
|
||||
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
||||
Auto mode distributes image generation requests across all configured
|
||||
providers for optimal performance and rate limit protection.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Global config (read-only) */}
|
||||
{isGlobal && !isAutoMode && config && (
|
||||
<>
|
||||
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5">
|
||||
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
|
||||
<AlertCircle className="size-4 text-amber-500" />
|
||||
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
||||
Global configurations are read-only. To customize, create a new model.
|
||||
|
|
@ -372,29 +346,11 @@ export function ImageConfigSidebar({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-6 border-t border-border/50 mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 gap-2"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Loading..." : "Use This Model"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create / Edit form */}
|
||||
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Name *</Label>
|
||||
<Input
|
||||
|
|
@ -404,7 +360,6 @@ export function ImageConfigSidebar({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Description</Label>
|
||||
<Input
|
||||
|
|
@ -418,7 +373,6 @@ export function ImageConfigSidebar({
|
|||
|
||||
<Separator />
|
||||
|
||||
{/* Provider */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Provider *</Label>
|
||||
<Select
|
||||
|
|
@ -430,20 +384,16 @@ export function ImageConfigSidebar({
|
|||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="bg-muted dark:border-neutral-700">
|
||||
{IMAGE_GEN_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{p.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.example}</span>
|
||||
</div>
|
||||
<SelectItem key={p.value} value={p.value} description={p.example}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Model Name */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Model Name *</Label>
|
||||
{suggestedModels.length > 0 ? (
|
||||
|
|
@ -452,14 +402,17 @@ export function ImageConfigSidebar({
|
|||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
className="w-full justify-between font-normal bg-transparent"
|
||||
>
|
||||
{formData.model_name || "Select or type a model..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<PopoverContent
|
||||
className="w-full p-0 bg-muted dark:border-neutral-700"
|
||||
align="start"
|
||||
>
|
||||
<Command className="bg-transparent">
|
||||
<CommandInput
|
||||
placeholder="Search or type model..."
|
||||
value={formData.model_name}
|
||||
|
|
@ -513,11 +466,8 @@ export function ImageConfigSidebar({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium flex items-center gap-1.5">
|
||||
<Key className="h-3.5 w-3.5" /> API Key *
|
||||
</Label>
|
||||
<Label className="text-sm font-medium">API Key *</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
|
|
@ -526,7 +476,6 @@ export function ImageConfigSidebar({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* API Base */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Base URL</Label>
|
||||
<Input
|
||||
|
|
@ -536,7 +485,6 @@ export function ImageConfigSidebar({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Azure API Version */}
|
||||
{formData.provider === "AZURE_OPENAI" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Version (Azure)</Label>
|
||||
|
|
@ -549,28 +497,56 @@ export function ImageConfigSidebar({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 gap-2 bg-gradient-to-r from-teal-500 to-cyan-600 hover:from-teal-600 hover:to-cyan-700"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !isFormValid}
|
||||
>
|
||||
{isSubmitting ? <Spinner size="sm" className="mr-2" /> : null}
|
||||
{mode === "edit" ? "Save Changes" : "Create & Use"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed footer */}
|
||||
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{mode === "create" || (mode === "edit" && !isGlobal) ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !isFormValid}
|
||||
className="text-sm h-9 min-w-[120px]"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
{mode === "edit" ? "Saving" : "Creating"}
|
||||
</>
|
||||
) : mode === "edit" ? (
|
||||
"Save Changes"
|
||||
) : (
|
||||
"Create & Use"
|
||||
)}
|
||||
</Button>
|
||||
) : isAutoMode ? (
|
||||
<Button
|
||||
className="text-sm h-9 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Loading..." : "Use Auto Mode"}
|
||||
</Button>
|
||||
) : isGlobal && config ? (
|
||||
<Button
|
||||
className="text-sm h-9 gap-2"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Loading..." : "Use This Model"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
|
|
@ -578,5 +554,5 @@ export function ImageConfigSidebar({
|
|||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null;
|
||||
return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null;
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Bot, ChevronRight, Globe, Shuffle, User, X, Zap } from "lucide-react";
|
||||
import { AlertCircle, X, Zap } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -15,13 +15,15 @@ import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-c
|
|||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type {
|
||||
GlobalNewLLMConfig,
|
||||
LiteLLMProvider,
|
||||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModelConfigSidebarProps {
|
||||
interface ModelConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
|
||||
|
|
@ -30,28 +32,34 @@ interface ModelConfigSidebarProps {
|
|||
mode: "create" | "edit" | "view";
|
||||
}
|
||||
|
||||
export function ModelConfigSidebar({
|
||||
export function ModelConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
isGlobal,
|
||||
searchSpaceId,
|
||||
mode,
|
||||
}: ModelConfigSidebarProps) {
|
||||
}: ModelConfigDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle SSR - only render portal on client
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Mutations - use mutateAsync from the atom value
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
|
||||
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
|
||||
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
|
|
@ -62,10 +70,8 @@ export function ModelConfigSidebar({
|
|||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
// Check if this is Auto mode
|
||||
const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode;
|
||||
|
||||
// Get title based on mode
|
||||
const getTitle = () => {
|
||||
if (mode === "create") return "Add New Configuration";
|
||||
if (isAutoMode) return "Auto Mode (Fastest)";
|
||||
|
|
@ -73,19 +79,23 @@ export function ModelConfigSidebar({
|
|||
return "Edit Configuration";
|
||||
};
|
||||
|
||||
// Handle form submit
|
||||
const getSubtitle = () => {
|
||||
if (mode === "create") return "Set up a new LLM provider for this search space";
|
||||
if (isAutoMode) return "Automatically routes requests across providers";
|
||||
if (isGlobal) return "Read-only global configuration";
|
||||
return "Update your configuration settings";
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (data: LLMConfigFormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
// Create new config
|
||||
const result = await createConfig({
|
||||
...data,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
|
||||
// Assign the new config to the agent role
|
||||
if (result?.id) {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
|
|
@ -98,7 +108,6 @@ export function ModelConfigSidebar({
|
|||
toast.success("Configuration created and assigned!");
|
||||
onOpenChange(false);
|
||||
} else if (!isGlobal && config) {
|
||||
// Update existing user config
|
||||
await updateConfig({
|
||||
id: config.id,
|
||||
data: {
|
||||
|
|
@ -137,7 +146,6 @@ export function ModelConfigSidebar({
|
|||
]
|
||||
);
|
||||
|
||||
// Handle "Use this model" for global configs
|
||||
const handleUseGlobalConfig = useCallback(async () => {
|
||||
if (!config || !isGlobal) return;
|
||||
setIsSubmitting(true);
|
||||
|
|
@ -160,7 +168,7 @@ export function ModelConfigSidebar({
|
|||
|
||||
if (!mounted) return null;
|
||||
|
||||
const sidebarContent = (
|
||||
const dialogContent = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
|
|
@ -169,93 +177,84 @@ export function ModelConfigSidebar({
|
|||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-[24] bg-black/20 backdrop-blur-sm"
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed inset-0 z-[24] bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{/* Sidebar Panel */}
|
||||
{/* Dialog */}
|
||||
<motion.div
|
||||
initial={{ x: "100%", opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: "100%", opacity: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
damping: 30,
|
||||
stiffness: 300,
|
||||
}}
|
||||
className={cn(
|
||||
"fixed right-0 top-0 z-[25] h-full w-full sm:w-[480px] lg:w-[540px]",
|
||||
"bg-background border-l border-border/50 shadow-2xl",
|
||||
"flex flex-col"
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className="fixed inset-0 z-[25] flex items-center justify-center p-4 sm:p-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
"flex items-center justify-between px-6 py-4 border-b border-border/50",
|
||||
isAutoMode ? "bg-gradient-to-r from-violet-500/10 to-purple-500/10" : "bg-muted/20"
|
||||
"relative w-full max-w-lg h-[85vh]",
|
||||
"rounded-xl bg-background shadow-2xl ring-1 ring-border/50",
|
||||
"dark:bg-neutral-900 dark:ring-white/5",
|
||||
"flex flex-col overflow-hidden"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center size-10 rounded-xl",
|
||||
isAutoMode ? "bg-gradient-to-br from-violet-500 to-purple-600" : "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
{isAutoMode ? (
|
||||
<Shuffle className="size-5 text-white" />
|
||||
) : (
|
||||
<Bot className="size-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold">{getTitle()}</h2>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{isAutoMode ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
||||
>
|
||||
<Zap className="size-3" />
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4">
|
||||
<div className="space-y-1 pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||
{isAutoMode && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Recommended
|
||||
</Badge>
|
||||
) : isGlobal ? (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Globe className="size-3" />
|
||||
)}
|
||||
{isGlobal && !isAutoMode && mode !== "create" && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Global
|
||||
</Badge>
|
||||
) : mode !== "create" ? (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<User className="size-3" />
|
||||
)}
|
||||
{!isGlobal && mode !== "create" && !isAutoMode && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Custom
|
||||
</Badge>
|
||||
) : null}
|
||||
{config && !isAutoMode && (
|
||||
<span className="text-xs text-muted-foreground">{config.model_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||
{config && !isAutoMode && mode !== "create" && (
|
||||
<p className="text-xs font-mono text-muted-foreground/70">
|
||||
{config.model_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-4 top-4 h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 w-8 rounded-full"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content - use overflow-y-auto instead of ScrollArea for better compatibility */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6">
|
||||
{/* Auto mode info banner */}
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto px-6 py-5"
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
{isAutoMode && (
|
||||
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5">
|
||||
<Shuffle className="size-4 text-violet-500" />
|
||||
<Alert className="mb-5 border-violet-500/30 bg-violet-500/5">
|
||||
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
|
||||
Auto mode automatically distributes requests across all available LLM
|
||||
providers to optimize performance and avoid rate limits.
|
||||
|
|
@ -263,9 +262,8 @@ export function ModelConfigSidebar({
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Global config notice */}
|
||||
{isGlobal && !isAutoMode && mode !== "create" && (
|
||||
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5">
|
||||
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
|
||||
<AlertCircle className="size-4 text-amber-500" />
|
||||
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
||||
Global configurations are read-only. To customize settings, create a new
|
||||
|
|
@ -274,20 +272,17 @@ export function ModelConfigSidebar({
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{mode === "create" ? (
|
||||
<LLMConfigForm
|
||||
searchSpaceId={searchSpaceId}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
isSubmitting={isSubmitting}
|
||||
mode="create"
|
||||
submitLabel="Create & Use"
|
||||
formId="model-config-form"
|
||||
hideActions
|
||||
/>
|
||||
) : isAutoMode && config ? (
|
||||
// Special view for Auto mode
|
||||
<div className="space-y-6">
|
||||
{/* Auto Mode Features */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
|
|
@ -339,36 +334,9 @@ export function ModelConfigSidebar({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4 border-t border-border/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>Loading...</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="size-4" />
|
||||
Use Auto Mode
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : isGlobal && config ? (
|
||||
// Read-only view for global configs
|
||||
<div className="space-y-6">
|
||||
{/* Config Details */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
|
|
@ -436,43 +404,17 @@ export function ModelConfigSidebar({
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4 border-t border-border/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 gap-2"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>Loading...</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="size-4" />
|
||||
Use This Model
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : config ? (
|
||||
// Edit form for user configs
|
||||
<LLMConfigForm
|
||||
searchSpaceId={searchSpaceId}
|
||||
initialData={{
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
provider: config.provider,
|
||||
provider: config.provider as LiteLLMProvider,
|
||||
custom_provider: config.custom_provider,
|
||||
model_name: config.model_name,
|
||||
api_key: config.api_key,
|
||||
api_key: "api_key" in config ? (config.api_key as string) : "",
|
||||
api_base: config.api_base,
|
||||
litellm_params: config.litellm_params,
|
||||
system_instructions: config.system_instructions,
|
||||
|
|
@ -481,13 +423,61 @@ export function ModelConfigSidebar({
|
|||
search_space_id: searchSpaceId,
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
isSubmitting={isSubmitting}
|
||||
mode="edit"
|
||||
submitLabel="Save Changes"
|
||||
formId="model-config-form"
|
||||
hideActions
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Fixed footer */}
|
||||
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{mode === "create" || (!isGlobal && !isAutoMode && config) ? (
|
||||
<Button
|
||||
type="submit"
|
||||
form="model-config-form"
|
||||
disabled={isSubmitting}
|
||||
className="text-sm h-9 min-w-[120px]"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
{mode === "edit" ? "Saving" : "Creating"}
|
||||
</>
|
||||
) : mode === "edit" ? (
|
||||
"Save Changes"
|
||||
) : (
|
||||
"Create & Use"
|
||||
)}
|
||||
</Button>
|
||||
) : isAutoMode ? (
|
||||
<Button
|
||||
className="text-sm h-9 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Loading..." : "Use Auto Mode"}
|
||||
</Button>
|
||||
) : isGlobal && config ? (
|
||||
<Button
|
||||
className="text-sm h-9 gap-2"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Loading..." : "Use This Model"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
|
|
@ -495,5 +485,5 @@ export function ModelConfigSidebar({
|
|||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null;
|
||||
return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Zap } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { type UIEvent, useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
globalImageGenConfigsAtom,
|
||||
|
|
@ -57,6 +57,17 @@ export function ModelSelector({
|
|||
const [activeTab, setActiveTab] = useState<"llm" | "image">("llm");
|
||||
const [llmSearchQuery, setLlmSearchQuery] = useState("");
|
||||
const [imageSearchQuery, setImageSearchQuery] = useState("");
|
||||
const [llmScrollPos, setLlmScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const [imageScrollPos, setImageScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const handleListScroll = useCallback(
|
||||
(setter: typeof setLlmScrollPos) => (e: UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setter(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// LLM data
|
||||
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
|
||||
|
|
@ -253,7 +264,7 @@ export function ModelSelector({
|
|||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-4 w-px bg-border/60 mx-0.5" />
|
||||
<div className="h-4 w-px bg-border/60 dark:bg-white/10 mx-0.5" />
|
||||
|
||||
{/* Image section */}
|
||||
{currentImageConfig ? (
|
||||
|
|
@ -280,7 +291,7 @@ export function ModelSelector({
|
|||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-muted dark:border dark:border-neutral-700 select-none"
|
||||
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
|
|
@ -289,7 +300,7 @@ export function ModelSelector({
|
|||
onValueChange={(v) => setActiveTab(v as "llm" | "image")}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="border-b border-border/80 dark:border-white/5">
|
||||
<div className="border-b border-border/80 dark:border-neutral-800">
|
||||
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
|
||||
<TabsTrigger
|
||||
value="llm"
|
||||
|
|
@ -312,7 +323,7 @@ export function ModelSelector({
|
|||
<TabsContent value="llm" className="mt-0">
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="rounded-none rounded-b-lg relative dark:bg-muted [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
className="rounded-none rounded-b-lg relative dark:bg-neutral-900 [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
>
|
||||
{totalLLMModels > 3 && (
|
||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||
|
|
@ -325,7 +336,14 @@ export function ModelSelector({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
|
||||
<CommandList
|
||||
className="max-h-[300px] md:max-h-[400px] overflow-y-auto"
|
||||
onScroll={handleListScroll(setLlmScrollPos)}
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${llmScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${llmScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${llmScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${llmScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
<CommandEmpty className="py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Bot className="size-8 text-muted-foreground" />
|
||||
|
|
@ -350,8 +368,8 @@ export function ModelSelector({
|
|||
onSelect={() => handleSelectLLM(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50 dark:hover:bg-white/10",
|
||||
isSelected && "bg-accent/80 dark:bg-white/10",
|
||||
"hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||
isSelected && "bg-accent/80 dark:bg-white/[0.06]",
|
||||
isAutoMode && ""
|
||||
)}
|
||||
>
|
||||
|
|
@ -426,8 +444,8 @@ export function ModelSelector({
|
|||
onSelect={() => handleSelectLLM(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50 dark:hover:bg-white/10",
|
||||
isSelected && "bg-accent/80 dark:bg-white/10"
|
||||
"hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||
isSelected && "bg-accent/80 dark:bg-white/[0.06]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
|
|
@ -471,11 +489,11 @@ export function ModelSelector({
|
|||
)}
|
||||
|
||||
{/* Add New LLM Config */}
|
||||
<div className="p-2 bg-muted/20 dark:bg-muted">
|
||||
<div className="p-2 bg-muted/20 dark:bg-neutral-900">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/10"
|
||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/[0.06]"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onAddNewLLM();
|
||||
|
|
@ -493,7 +511,7 @@ export function ModelSelector({
|
|||
<TabsContent value="image" className="mt-0">
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="rounded-none rounded-b-lg dark:bg-muted [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
className="rounded-none rounded-b-lg dark:bg-neutral-900 [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
>
|
||||
{totalImageModels > 3 && (
|
||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||
|
|
@ -505,7 +523,14 @@ export function ModelSelector({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
|
||||
<CommandList
|
||||
className="max-h-[300px] md:max-h-[400px] overflow-y-auto"
|
||||
onScroll={handleListScroll(setImageScrollPos)}
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${imageScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${imageScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${imageScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${imageScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
<CommandEmpty className="py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ImageIcon className="size-8 text-muted-foreground" />
|
||||
|
|
@ -528,8 +553,8 @@ export function ModelSelector({
|
|||
value={`img-g-${config.id}`}
|
||||
onSelect={() => handleSelectImage(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10",
|
||||
isSelected && "bg-accent/80 dark:bg-white/10",
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||
isSelected && "bg-accent/80 dark:bg-white/[0.06]",
|
||||
isAuto && ""
|
||||
)}
|
||||
>
|
||||
|
|
@ -593,8 +618,8 @@ export function ModelSelector({
|
|||
value={`img-u-${config.id}`}
|
||||
onSelect={() => handleSelectImage(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10",
|
||||
isSelected && "bg-accent/80 dark:bg-white/10"
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||
isSelected && "bg-accent/80 dark:bg-white/[0.06]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
|
|
@ -634,11 +659,11 @@ export function ModelSelector({
|
|||
|
||||
{/* Add New Image Config */}
|
||||
{onAddNewImage && (
|
||||
<div className="p-2 bg-muted/20 dark:bg-muted">
|
||||
<div className="p-2 bg-muted/20 dark:bg-neutral-900">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/10"
|
||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/[0.06]"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onAddNewImage();
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ function ReportPanelContent({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={`min-w-[180px] bg-muted dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`}
|
||||
className={`min-w-[180px] dark:bg-neutral-900 dark:border dark:border-white/5${insideDrawer ? " z-[100]" : ""}`}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => handleExport("md")}>
|
||||
Download Markdown
|
||||
|
|
@ -371,7 +371,7 @@ function ReportPanelContent({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={`min-w-[120px] bg-muted dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`}
|
||||
className={`min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5${insideDrawer ? " z-[100]" : ""}`}
|
||||
>
|
||||
{versions.map((v, i) => (
|
||||
<DropdownMenuItem
|
||||
|
|
|
|||
|
|
@ -578,10 +578,7 @@ function RolesContent({
|
|||
<DropdownMenuSeparator />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Role
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -2,16 +2,7 @@
|
|||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Key,
|
||||
MessageSquareQuote,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
|
@ -88,6 +79,8 @@ interface LLMConfigFormProps {
|
|||
submitLabel?: string;
|
||||
showAdvanced?: boolean;
|
||||
compact?: boolean;
|
||||
formId?: string;
|
||||
hideActions?: boolean;
|
||||
}
|
||||
|
||||
export function LLMConfigForm({
|
||||
|
|
@ -100,6 +93,8 @@ export function LLMConfigForm({
|
|||
submitLabel,
|
||||
showAdvanced = true,
|
||||
compact = false,
|
||||
formId,
|
||||
hideActions = false,
|
||||
}: LLMConfigFormProps) {
|
||||
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
|
||||
defaultSystemInstructionsAtom
|
||||
|
|
@ -164,11 +159,10 @@ export function LLMConfigForm({
|
|||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||
<form id={formId} onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||
{/* Model Configuration Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
<Bot className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
Model Configuration
|
||||
</div>
|
||||
|
||||
|
|
@ -179,16 +173,9 @@ export function LLMConfigForm({
|
|||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
|
||||
Configuration Name
|
||||
</FormLabel>
|
||||
<FormLabel className="text-xs sm:text-sm">Configuration Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., My GPT-4 Agent"
|
||||
className="transition-all focus-visible:ring-violet-500/50"
|
||||
{...field}
|
||||
/>
|
||||
<Input placeholder="e.g., My GPT-4 Agent" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -224,19 +211,18 @@ export function LLMConfigForm({
|
|||
<FormLabel className="text-xs sm:text-sm">LLM Provider</FormLabel>
|
||||
<Select value={field.value} onValueChange={handleProviderChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="transition-all focus:ring-violet-500/50">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
<SelectContent className="max-h-[300px] bg-muted dark:border-neutral-700">
|
||||
{LLM_PROVIDERS.map((provider) => (
|
||||
<SelectItem key={provider.value} value={provider.value}>
|
||||
<div className="flex flex-col py-0.5">
|
||||
<span className="font-medium">{provider.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{provider.description}
|
||||
</span>
|
||||
</div>
|
||||
<SelectItem
|
||||
key={provider.value}
|
||||
value={provider.value}
|
||||
description={provider.description}
|
||||
>
|
||||
{provider.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -290,7 +276,7 @@ export function LLMConfigForm({
|
|||
role="combobox"
|
||||
aria-expanded={modelComboboxOpen}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
"w-full justify-between font-normal bg-transparent",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
|
|
@ -299,8 +285,11 @@ export function LLMConfigForm({
|
|||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<PopoverContent
|
||||
className="w-full p-0 bg-muted dark:border-neutral-700"
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false} className="bg-transparent">
|
||||
<CommandInput
|
||||
placeholder={selectedProvider?.example || "Type model name..."}
|
||||
value={field.value}
|
||||
|
|
@ -371,10 +360,7 @@ export function LLMConfigForm({
|
|||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<Key className="h-3.5 w-3.5 text-amber-500" />
|
||||
API Key
|
||||
</FormLabel>
|
||||
<FormLabel className="text-xs sm:text-sm">API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
|
|
@ -460,10 +446,7 @@ export function LLMConfigForm({
|
|||
type="button"
|
||||
className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
Advanced Parameters
|
||||
</div>
|
||||
<span>Advanced Parameters</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
|
|
@ -501,10 +484,7 @@ export function LLMConfigForm({
|
|||
type="button"
|
||||
className="flex w-full items-center justify-between py-2 text-xs sm:text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquareQuote className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
System Instructions
|
||||
</div>
|
||||
<span>System Instructions</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
|
|
@ -575,42 +555,43 @@ export function LLMConfigForm({
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-3 pt-4",
|
||||
compact ? "justify-end" : "justify-center sm:justify-end"
|
||||
)}
|
||||
>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
className="text-xs sm:text-sm h-9 sm:h-10"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="gap-2 min-w-[140px] sm:min-w-[160px] text-xs sm:text-sm h-9 sm:h-10"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
{mode === "edit" ? "Updating..." : "Creating"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!compact && <Rocket className="h-3.5 w-3.5 sm:h-4 sm:w-4" />}
|
||||
{submitLabel ?? (mode === "edit" ? "Update Configuration" : "Create Configuration")}
|
||||
</>
|
||||
{!hideActions && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-3 pt-4",
|
||||
compact ? "justify-end" : "justify-center sm:justify-end"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
className="text-xs sm:text-sm h-9 sm:h-10"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="gap-2 min-w-[140px] sm:min-w-[160px] text-xs sm:text-sm h-9 sm:h-10"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
{mode === "edit" ? "Updating..." : "Creating"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{submitLabel ??
|
||||
(mode === "edit" ? "Update Configuration" : "Create Configuration")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useAtom } from "jotai";
|
||||
import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
|
|
@ -241,12 +241,7 @@ export function DocumentUploadTab({
|
|||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0"
|
||||
>
|
||||
<div className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0">
|
||||
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0">
|
||||
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
|
||||
|
|
@ -287,14 +282,10 @@ export function DocumentUploadTab({
|
|||
</div>
|
||||
</div>
|
||||
) : isDragActive ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-2 sm:gap-4"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 sm:gap-4">
|
||||
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" />
|
||||
<p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 sm:gap-4">
|
||||
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" />
|
||||
|
|
@ -329,124 +320,102 @@ export function DocumentUploadTab({
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{files.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card className={cardClass}>
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base sm:text-2xl">
|
||||
{t("selected_files", { count: files.length })}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs sm:text-sm">
|
||||
{t("total_size")}: {formatFileSize(totalFileSize)}
|
||||
</CardDescription>
|
||||
{files.length > 0 && (
|
||||
<Card className={cardClass}>
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base sm:text-2xl">
|
||||
{t("selected_files", { count: files.length })}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs sm:text-sm">
|
||||
{t("total_size")}: {formatFileSize(totalFileSize)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm shrink-0"
|
||||
onClick={() => setFiles([])}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{t("clear_all")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 sm:p-6 pt-0">
|
||||
<div className="space-y-2 sm:space-y-3 max-h-[250px] sm:max-h-[400px] overflow-y-auto">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={`${file.name}-${index}`}
|
||||
className={`flex items-center justify-between p-2 sm:p-4 rounded-lg border border-border ${cardClass} hover:bg-slate-400/10 dark:hover:bg-white/10 transition-colors`}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm sm:text-base font-medium truncate">{file.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatFileSize(file.size)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{file.type || "Unknown type"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm shrink-0"
|
||||
onClick={() => setFiles([])}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setFiles((prev) => prev.filter((_, i) => i !== index))}
|
||||
disabled={isUploading}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{t("clear_all")}
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 sm:p-6 pt-0">
|
||||
<div className="space-y-2 sm:space-y-3 max-h-[250px] sm:max-h-[400px] overflow-y-auto">
|
||||
<AnimatePresence>
|
||||
{files.map((file, index) => (
|
||||
<motion.div
|
||||
key={`${file.name}-${index}`}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className={`flex items-center justify-between p-2 sm:p-4 rounded-lg border border-border ${cardClass} hover:bg-slate-400/10 dark:hover:bg-white/10 transition-colors`}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm sm:text-base font-medium truncate">{file.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatFileSize(file.size)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{file.type || "Unknown type"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setFiles((prev) => prev.filter((_, i) => i !== index))}
|
||||
disabled={isUploading}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isUploading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-3 sm:mt-6 space-y-2 sm:space-y-3"
|
||||
>
|
||||
<Separator className="bg-border" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs sm:text-sm">
|
||||
<span>{t("uploading_files")}</span>
|
||||
<span>{Math.round(uploadProgress)}%</span>
|
||||
</div>
|
||||
<Progress value={uploadProgress} className="h-2" />
|
||||
</div>
|
||||
</motion.div>
|
||||
{isUploading && (
|
||||
<div className="mt-3 sm:mt-6 space-y-2 sm:space-y-3">
|
||||
<Separator className="bg-border" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs sm:text-sm">
|
||||
<span>{t("uploading_files")}</span>
|
||||
<span>{Math.round(uploadProgress)}%</span>
|
||||
</div>
|
||||
<Progress value={uploadProgress} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 sm:mt-6">
|
||||
<SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 sm:mt-6">
|
||||
<Button
|
||||
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium"
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || files.length === 0}
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
{t("uploading")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
{t("upload_button", { count: files.length })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="mt-3 sm:mt-6">
|
||||
<SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="mt-3 sm:mt-6"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Button
|
||||
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium"
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || files.length === 0}
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
{t("uploading")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
{t("upload_button", { count: files.length })}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Accordion
|
||||
type="single"
|
||||
|
|
@ -479,6 +448,6 @@ export function DocumentUploadTab({
|
|||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function AlertDialogOverlay({
|
|||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -45,7 +45,7 @@ function AlertDialogContent({
|
|||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-background dark:bg-neutral-900 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl ring-1 ring-border/50 dark:ring-white/5 p-6 shadow-2xl duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -113,7 +113,7 @@ function AlertDialogCancel({
|
|||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
className={cn(buttonVariants({ variant: "secondary" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
|
|
@ -88,11 +88,14 @@ function Calendar({
|
|||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent dark:bg-neutral-700",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
range_end: cn("rounded-r-md bg-accent dark:bg-neutral-700", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
"bg-accent dark:bg-neutral-700 text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
|
|
@ -164,7 +167,7 @@ function CalendarDayButton({
|
|||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent dark:data-[range-middle=true]:bg-neutral-700 data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:bg-neutral-700 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ function ContextMenuSubContent({
|
|||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
"bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -83,7 +83,7 @@ function ContextMenuContent({
|
|||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
"bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -107,8 +107,7 @@ function ContextMenuItem({
|
|||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive",
|
||||
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -190,7 +189,7 @@ function ContextMenuSeparator({
|
|||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
className={cn("bg-border dark:bg-neutral-700 -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
|||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 ring-1 ring-border/50 dark:ring-white/5 bg-background dark:bg-neutral-900 p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-xl focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 z-50 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 z-50 h-8 w-8 rounded-full inline-flex items-center justify-center text-muted-foreground transition-colors hover:text-foreground hover:bg-accent focus:outline-none disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ function DropdownMenuContent({
|
|||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
"bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border dark:border-neutral-700 p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -61,7 +61,7 @@ function DropdownMenuItem({
|
|||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -149,7 +149,7 @@ function DropdownMenuSeparator({
|
|||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
className={cn("bg-border dark:bg-neutral-700 -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -201,7 +201,7 @@ function DropdownMenuSubContent({
|
|||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
"bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border dark:border-neutral-700 p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import {
|
|||
} from "lucide-react";
|
||||
import { KEYS } from "platejs";
|
||||
import { useEditorReadOnly, useEditorRef } from "platejs/react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useEditorSave } from "@/components/editor/editor-save-context";
|
||||
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
|
||||
|
||||
|
|
@ -26,11 +26,20 @@ import { ModeToolbarButton } from "./mode-toolbar-button";
|
|||
import { ToolbarButton, ToolbarGroup } from "./toolbar";
|
||||
import { TurnIntoToolbarButton } from "./turn-into-toolbar-button";
|
||||
|
||||
function TooltipWithShortcut({ label, keys }: { label: string; keys: string[] }) {
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
{label}
|
||||
<ShortcutKbd keys={keys} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FixedToolbarButtons() {
|
||||
const readOnly = useEditorReadOnly();
|
||||
const editor = useEditorRef();
|
||||
const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave();
|
||||
const { shortcut } = usePlatformShortcut();
|
||||
const { shortcutKeys } = usePlatformShortcut();
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center">
|
||||
|
|
@ -40,7 +49,7 @@ export function FixedToolbarButtons() {
|
|||
<>
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
tooltip={`Undo ${shortcut("Mod", "Z")}`}
|
||||
tooltip={<TooltipWithShortcut label="Undo" keys={shortcutKeys("Mod", "Z")} />}
|
||||
onClick={() => {
|
||||
editor.undo();
|
||||
editor.tf.focus();
|
||||
|
|
@ -50,7 +59,9 @@ export function FixedToolbarButtons() {
|
|||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
tooltip={`Redo ${shortcut("Mod", "Shift", "Z")}`}
|
||||
tooltip={
|
||||
<TooltipWithShortcut label="Redo" keys={shortcutKeys("Mod", "Shift", "Z")} />
|
||||
}
|
||||
onClick={() => {
|
||||
editor.redo();
|
||||
editor.tf.focus();
|
||||
|
|
@ -66,35 +77,51 @@ export function FixedToolbarButtons() {
|
|||
</ToolbarGroup>
|
||||
|
||||
<ToolbarGroup>
|
||||
<MarkToolbarButton nodeType={KEYS.bold} tooltip={`Bold ${shortcut("Mod", "B")}`}>
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.bold}
|
||||
tooltip={<TooltipWithShortcut label="Bold" keys={shortcutKeys("Mod", "B")} />}
|
||||
>
|
||||
<BoldIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.italic} tooltip={`Italic ${shortcut("Mod", "I")}`}>
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.italic}
|
||||
tooltip={<TooltipWithShortcut label="Italic" keys={shortcutKeys("Mod", "I")} />}
|
||||
>
|
||||
<ItalicIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.underline}
|
||||
tooltip={`Underline ${shortcut("Mod", "U")}`}
|
||||
tooltip={<TooltipWithShortcut label="Underline" keys={shortcutKeys("Mod", "U")} />}
|
||||
>
|
||||
<UnderlineIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.strikethrough}
|
||||
tooltip={`Strikethrough ${shortcut("Mod", "Shift", "X")}`}
|
||||
tooltip={
|
||||
<TooltipWithShortcut
|
||||
label="Strikethrough"
|
||||
keys={shortcutKeys("Mod", "Shift", "X")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<StrikethroughIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.code} tooltip={`Code ${shortcut("Mod", "E")}`}>
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.code}
|
||||
tooltip={<TooltipWithShortcut label="Code" keys={shortcutKeys("Mod", "E")} />}
|
||||
>
|
||||
<Code2Icon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.highlight}
|
||||
tooltip={`Highlight ${shortcut("Mod", "Shift", "H")}`}
|
||||
tooltip={
|
||||
<TooltipWithShortcut label="Highlight" keys={shortcutKeys("Mod", "Shift", "H")} />
|
||||
}
|
||||
>
|
||||
<HighlighterIcon />
|
||||
</MarkToolbarButton>
|
||||
|
|
@ -113,7 +140,13 @@ export function FixedToolbarButtons() {
|
|||
{!readOnly && onSave && hasUnsavedChanges && (
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
tooltip={isSaving ? "Saving..." : `Save ${shortcut("Mod", "S")}`}
|
||||
tooltip={
|
||||
isSaving ? (
|
||||
"Saving..."
|
||||
) : (
|
||||
<TooltipWithShortcut label="Save" keys={shortcutKeys("Mod", "S")} />
|
||||
)
|
||||
}
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ function PopoverContent({
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
|
||||
"bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border dark:border-neutral-700 p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ function SelectContent({
|
|||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
|
||||
"bg-muted text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border dark:border-neutral-700 shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
|
|
@ -88,18 +88,28 @@ function SelectLabel({ className, ...props }: React.ComponentProps<typeof Select
|
|||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
description,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item> & {
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent/50 focus:text-accent-foreground hover:bg-accent/50 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm outline-hidden select-none transition-all data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 data-[highlighted]:bg-accent/50",
|
||||
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 hover:bg-accent dark:hover:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm outline-hidden select-none transition-all data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 data-[highlighted]:bg-accent dark:data-[highlighted]:bg-neutral-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description ? (
|
||||
<div className="flex flex-col py-0.5">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
<span className="text-xs text-muted-foreground">{description}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
)}
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
|
@ -111,7 +121,7 @@ function SelectSeparator({
|
|||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
className={cn("bg-border dark:bg-neutral-700 pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
23
surfsense_web/components/ui/shortcut-kbd.tsx
Normal file
23
surfsense_web/components/ui/shortcut-kbd.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ShortcutKbdProps {
|
||||
keys: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ShortcutKbd({ keys, className }: ShortcutKbdProps) {
|
||||
if (keys.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span className={cn("ml-2 inline-flex items-center gap-0.5 text-white/50", className)}>
|
||||
{keys.map((key) => (
|
||||
<kbd
|
||||
key={key}
|
||||
className="inline-flex size-[16px] items-center justify-center rounded-[3px] bg-white/[0.08] font-sans text-[10px] leading-none"
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ function TooltipContent({
|
|||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none",
|
||||
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue