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

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

View file

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

View file

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

View file

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

View file

@ -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) => (

View file

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

View file

@ -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 */}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,216 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import React, { useEffect, useState } from "react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
import { getThreadFull } from "@/lib/chat/thread-persistence";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface BreadcrumbItemInterface {
label: string;
href?: string;
}
export function DashboardBreadcrumb() {
const t = useTranslations("breadcrumb");
const pathname = usePathname();
// Extract search space ID and chat ID from pathname
const segments = pathname.split("/").filter(Boolean);
const searchSpaceId = segments[0] === "dashboard" && segments[1] ? segments[1] : null;
const { data: searchSpace } = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId || ""),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
enabled: !!searchSpaceId,
});
// Extract chat thread ID from pathname for chat pages
const chatThreadId = segments[2] === "new-chat" && segments[3] ? segments[3] : null;
// Fetch thread details when on a chat page with a thread ID
const { data: threadData } = useQuery({
queryKey: ["threads", searchSpaceId, "detail", chatThreadId],
queryFn: () => getThreadFull(Number(chatThreadId)),
enabled: !!chatThreadId && !!searchSpaceId,
});
// State to store document title for editor breadcrumb
const [documentTitle, setDocumentTitle] = useState<string | null>(null);
// Fetch document title when on editor page
useEffect(() => {
if (segments[2] === "editor" && segments[3] && searchSpaceId) {
const documentId = segments[3];
// Skip fetch for "new" notes
if (documentId === "new") {
setDocumentTitle(null);
return;
}
const token = getBearerToken();
if (token) {
authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
{ method: "GET" }
)
.then((res) => res.json())
.then((data) => {
if (data.title) {
setDocumentTitle(data.title);
}
})
.catch(() => {
// If fetch fails, just use the document ID
setDocumentTitle(null);
});
}
} else {
setDocumentTitle(null);
}
}, [segments, searchSpaceId]);
// Parse the pathname to create breadcrumb items
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
const segments = path.split("/").filter(Boolean);
const breadcrumbs: BreadcrumbItemInterface[] = [];
// Handle search space (start directly with search space, skip "Dashboard")
if (segments[0] === "dashboard" && segments[1]) {
// Use the actual search space name if available, otherwise fall back to the ID
const searchSpaceLabel = searchSpace?.name || `${t("search_space")} ${segments[1]}`;
breadcrumbs.push({
label: searchSpaceLabel,
href: `/dashboard/${segments[1]}`,
});
// Handle specific sections
if (segments[2]) {
const section = segments[2];
let sectionLabel = section.charAt(0).toUpperCase() + section.slice(1);
// Map section names to more readable labels
const sectionLabels: Record<string, string> = {
"new-chat": t("chat") || "Chat",
documents: t("documents"),
logs: t("logs"),
settings: t("settings"),
editor: t("editor"),
};
sectionLabel = sectionLabels[section] || sectionLabel;
// Handle sub-sections
if (segments[3]) {
const subSection = segments[3];
// Handle editor sub-sections (document ID)
if (section === "editor") {
// Handle special cases for editor
let documentLabel: string;
if (subSection === "new") {
documentLabel = "New Note";
} else {
documentLabel = documentTitle || subSection;
}
breadcrumbs.push({
label: t("documents"),
href: `/dashboard/${segments[1]}/documents`,
});
breadcrumbs.push({
label: sectionLabel,
href: `/dashboard/${segments[1]}/documents`,
});
breadcrumbs.push({ label: documentLabel });
return breadcrumbs;
}
// Handle documents sub-sections
if (section === "documents") {
const documentLabels: Record<string, string> = {
upload: t("upload_documents"),
webpage: t("add_webpages"),
};
const documentLabel = documentLabels[subSection] || subSection;
breadcrumbs.push({
label: t("documents"),
href: `/dashboard/${segments[1]}/documents`,
});
breadcrumbs.push({ label: documentLabel });
return breadcrumbs;
}
// Handle new-chat sub-sections (thread IDs)
// Show the chat title if available, otherwise fall back to "Chat"
if (section === "new-chat") {
const chatLabel = threadData?.title || t("chat") || "Chat";
breadcrumbs.push({
label: chatLabel,
});
return breadcrumbs;
}
// Handle other sub-sections
let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
const subSectionLabels: Record<string, string> = {
upload: t("upload_documents"),
youtube: t("add_youtube"),
webpage: t("add_webpages"),
manage: t("manage"),
};
subSectionLabel = subSectionLabels[subSection] || subSectionLabel;
breadcrumbs.push({
label: sectionLabel,
href: `/dashboard/${segments[1]}/${section}`,
});
breadcrumbs.push({ label: subSectionLabel });
} else {
breadcrumbs.push({ label: sectionLabel });
}
}
}
return breadcrumbs;
};
const breadcrumbs = generateBreadcrumbs(pathname);
if (breadcrumbs.length === 0) {
return null; // Don't show breadcrumbs for root dashboard
}
return (
<Breadcrumb className="select-none">
<BreadcrumbList>
{breadcrumbs.map((item, index) => (
<React.Fragment key={`${index}-${item.href || item.label}`}>
<BreadcrumbItem>
{index === breadcrumbs.length - 1 ? (
<BreadcrumbPage>{item.label}</BreadcrumbPage>
) : (
<BreadcrumbLink href={item.href}>{item.label}</BreadcrumbLink>
)}
</BreadcrumbItem>
{index < breadcrumbs.length - 1 && <BreadcrumbSeparator />}
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
);
}

View file

@ -1,6 +1,6 @@
"use client";
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>

View file

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

View file

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

View file

@ -138,20 +138,20 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
)}
/>
<DialogFooter className="flex-row gap-2 pt-2 sm:pt-3">
<DialogFooter className="flex-row justify-end gap-2 pt-2 sm:pt-3">
<Button
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 ? (
<>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,211 @@
"use client";
import { useAtom, useAtomValue } from "jotai";
import { ChevronLeft } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
import {
DocumentsTableShell,
type SortKey,
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell";
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { Button } from "@/components/ui/button";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useDocumentSearch } from "@/hooks/use-document-search";
import { useDocuments } from "@/hooks/use-documents";
import { useMediaQuery } from "@/hooks/use-media-query";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
interface DocumentsSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) {
const t = useTranslations("documents");
const tSidebar = useTranslations("sidebar");
const params = useParams();
const isMobile = !useMediaQuery("(min-width: 640px)");
const searchSpaceId = Number(params.search_space_id);
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
const [sortKey, setSortKey] = useState<SortKey>("created_at");
const [sortDesc, setSortDesc] = useState(true);
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]);
const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
if (isMentioned) {
setSidebarDocs((prev) => prev.filter((d) => d.id !== doc.id));
} else {
setSidebarDocs((prev) => {
if (prev.some((d) => d.id === doc.id)) return prev;
return [
...prev,
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
];
});
}
},
[setSidebarDocs]
);
const isSearchMode = !!debouncedSearch.trim();
const {
documents: realtimeDocuments,
typeCounts: realtimeTypeCounts,
loading: realtimeLoading,
loadingMore: realtimeLoadingMore,
hasMore: realtimeHasMore,
loadMore: realtimeLoadMore,
error: realtimeError,
} = useDocuments(searchSpaceId, activeTypes, sortKey, sortDesc ? "desc" : "asc");
const {
documents: searchDocuments,
loading: searchLoading,
loadingMore: searchLoadingMore,
hasMore: searchHasMore,
loadMore: searchLoadMore,
error: searchError,
removeItems: searchRemoveItems,
} = useDocumentSearch(searchSpaceId, debouncedSearch, activeTypes, isSearchMode && open);
const displayDocs = isSearchMode ? searchDocuments : realtimeDocuments;
const loading = isSearchMode ? searchLoading : realtimeLoading;
const error = isSearchMode ? searchError : !!realtimeError;
const hasMore = isSearchMode ? searchHasMore : realtimeHasMore;
const loadingMore = isSearchMode ? searchLoadingMore : realtimeLoadingMore;
const onLoadMore = isSearchMode ? searchLoadMore : realtimeLoadMore;
const onToggleType = (type: DocumentTypeEnum, checked: boolean) => {
setActiveTypes((prev) => {
if (checked) {
return prev.includes(type) ? prev : [...prev, type];
}
return prev.filter((t) => t !== type);
});
};
const handleDeleteDocument = useCallback(
async (id: number): Promise<boolean> => {
try {
await deleteDocumentMutation({ id });
toast.success(t("delete_success") || "Document deleted");
setSidebarDocs((prev) => prev.filter((d) => d.id !== id));
if (isSearchMode) {
searchRemoveItems([id]);
}
return true;
} catch (e) {
console.error("Error deleting document:", e);
return false;
}
},
[deleteDocumentMutation, isSearchMode, t, searchRemoveItems, setSidebarDocs]
);
const sortKeyRef = useRef(sortKey);
const sortDescRef = useRef(sortDesc);
sortKeyRef.current = sortKey;
sortDescRef.current = sortDesc;
const handleSortChange = useCallback((key: SortKey) => {
const currentKey = sortKeyRef.current;
const currentDesc = sortDescRef.current;
if (currentKey === key && currentDesc) {
setSortKey("created_at");
setSortDesc(true);
} else if (currentKey === key) {
setSortDesc(true);
} else {
setSortKey(key);
setSortDesc(false);
}
}, []);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
const documentsContent = (
<>
<div className="shrink-0 p-4 pb-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{tSidebar("close") || "Close"}</span>
</Button>
)}
<h2 className="text-lg font-semibold">{t("title") || "Documents"}</h2>
</div>
</div>
</div>
<div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
<div className="px-4 pb-2">
<DocumentsFilters
typeCounts={realtimeTypeCounts}
onSearch={setSearch}
searchValue={search}
onToggleType={onToggleType}
activeTypes={activeTypes}
/>
</div>
<DocumentsTableShell
documents={displayDocs}
loading={!!loading}
error={!!error}
sortKey={sortKey}
sortDesc={sortDesc}
onSortChange={handleSortChange}
deleteDocument={handleDeleteDocument}
searchSpaceId={String(searchSpaceId)}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={onLoadMore}
isSearchMode={isSearchMode}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
/>
</div>
</>
);
return (
<SidebarSlideOutPanel
open={open}
onOpenChange={onOpenChange}
ariaLabel={t("title") || "Documents"}
width={isMobile ? undefined : 480}
>
{documentsContent}
</SidebarSlideOutPanel>
);
}

View file

@ -22,6 +22,7 @@ import {
import { useParams, useRouter } from "next/navigation";
import { 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}

View file

@ -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">

View file

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

View file

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

View file

@ -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"

View file

@ -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" />}

View file

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

View file

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

View file

@ -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">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,100 +0,0 @@
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View file

@ -88,11 +88,14 @@ function Calendar({
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
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
)}

View file

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

View file

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

View file

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

View file

@ -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"

View file

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

View file

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

View file

@ -0,0 +1,23 @@
import { cn } from "@/lib/utils";
interface ShortcutKbdProps {
keys: string[];
className?: string;
}
export function ShortcutKbd({ keys, className }: ShortcutKbdProps) {
if (keys.length === 0) return null;
return (
<span className={cn("ml-2 inline-flex items-center gap-0.5 text-white/50", className)}>
{keys.map((key) => (
<kbd
key={key}
className="inline-flex size-[16px] items-center justify-center rounded-[3px] bg-white/[0.08] font-sans text-[10px] leading-none"
>
{key}
</kbd>
))}
</span>
);
}

View file

@ -44,7 +44,7 @@ function TooltipContent({
data-slot="tooltip-content"
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}