Merge remote-tracking branch 'upstream/dev' into feat/sur-159

This commit is contained in:
Anish Sarkar 2026-02-11 03:47:51 +05:30
commit fc64f65876
86 changed files with 6332 additions and 5653 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { BadgeCheck, Loader2, LogOut } from "lucide-react";
import { BadgeCheck, LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@ -14,6 +14,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { logout } from "@/lib/auth-utils";
import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events";
@ -98,7 +99,7 @@ export function UserDropdown({
disabled={isLoggingOut}
>
{isLoggingOut ? (
<Loader2 className="mr-2 h-3.5 w-3.5 md:h-4 md:w-4 animate-spin" />
<Spinner size="sm" className="mr-2" />
) : (
<LogOut className="mr-2 h-3.5 w-3.5 md:h-4 md:w-4" />
)}

View file

@ -1,377 +0,0 @@
"use client";
import {
AttachmentPrimitive,
ComposerPrimitive,
MessagePrimitive,
useAssistantApi,
useAssistantState,
} from "@assistant-ui/react";
import { FileText, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react";
import Image from "next/image";
import { type FC, type PropsWithChildren, useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/shallow";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useDocumentUploadDialog } from "./document-upload-popup";
const useFileSrc = (file: File | undefined) => {
const [src, setSrc] = useState<string | undefined>(undefined);
useEffect(() => {
if (!file) {
setSrc(undefined);
return;
}
const objectUrl = URL.createObjectURL(file);
setSrc(objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
};
}, [file]);
return src;
};
const useAttachmentSrc = () => {
const { file, src } = useAssistantState(
useShallow(({ attachment }): { file?: File; src?: string } => {
if (!attachment || attachment.type !== "image") return {};
// First priority: use File object if available (for new uploads)
if (attachment.file) return { file: attachment.file };
// Second priority: use stored imageDataUrl (for persisted messages)
// This is stored in our custom ChatAttachment interface
const customAttachment = attachment as { imageDataUrl?: string };
if (customAttachment.imageDataUrl) {
return { src: customAttachment.imageDataUrl };
}
// Third priority: try to extract from content array (standard assistant-ui format)
if (Array.isArray(attachment.content)) {
const contentSrc = attachment.content.filter((c) => c.type === "image")[0]?.image;
if (contentSrc) return { src: contentSrc };
}
return {};
})
);
return useFileSrc(file) ?? src;
};
type AttachmentPreviewProps = {
src: string;
};
const AttachmentPreview: FC<AttachmentPreviewProps> = ({ src }) => {
const [isLoaded, setIsLoaded] = useState(false);
return (
<Image
src={src}
alt="Image Preview"
width={1}
height={1}
className={
isLoaded
? "aui-attachment-preview-image-loaded block h-auto max-h-[80vh] w-auto max-w-full object-contain"
: "aui-attachment-preview-image-loading hidden"
}
onLoadingComplete={() => setIsLoaded(true)}
priority={false}
/>
);
};
const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => {
const src = useAttachmentSrc();
if (!src) return children;
return (
<Dialog>
<DialogTrigger
className="aui-attachment-preview-trigger cursor-pointer transition-colors hover:bg-accent/50"
asChild
>
{children}
</DialogTrigger>
<DialogContent className="aui-attachment-preview-dialog-content p-2 sm:max-w-3xl [&>button]:rounded-full [&>button]:bg-foreground/60 [&>button]:p-1 [&>button]:opacity-100 [&>button]:ring-0! [&_svg]:text-background [&>button]:hover:[&_svg]:text-destructive">
<DialogTitle className="aui-sr-only sr-only">Image Attachment Preview</DialogTitle>
<div className="aui-attachment-preview relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden bg-background">
<AttachmentPreview src={src} />
</div>
</DialogContent>
</Dialog>
);
};
const AttachmentThumb: FC = () => {
const isImage = useAssistantState(({ attachment }) => attachment?.type === "image");
// Check if actively processing (running AND progress < 100)
// When progress is 100, processing is done but waiting for send()
const isProcessing = useAssistantState(({ attachment }) => {
const status = attachment?.status;
if (status?.type !== "running") return false;
// If progress is defined and equals 100, processing is complete
const progress = (status as { type: "running"; progress?: number }).progress;
return progress === undefined || progress < 100;
});
const src = useAttachmentSrc();
// Show loading spinner only when actively processing (not when done and waiting for send)
if (isProcessing) {
return (
<div className="flex h-full w-full items-center justify-center bg-muted">
<Spinner size="md" className="text-muted-foreground" />
</div>
);
}
return (
<Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none">
<AvatarImage
src={src}
alt="Attachment preview"
className="aui-attachment-tile-image object-cover"
/>
<AvatarFallback delayMs={isImage ? 200 : 0}>
<FileText className="aui-attachment-tile-fallback-icon size-8 text-muted-foreground" />
</AvatarFallback>
</Avatar>
);
};
const AttachmentUI: FC = () => {
const api = useAssistantApi();
const isComposer = api.attachment.source === "composer";
const isImage = useAssistantState(({ attachment }) => attachment?.type === "image");
// Check if actively processing (running AND progress < 100)
// When progress is 100, processing is done but waiting for send()
const isProcessing = useAssistantState(({ attachment }) => {
const status = attachment?.status;
if (status?.type !== "running") return false;
const progress = (status as { type: "running"; progress?: number }).progress;
return progress === undefined || progress < 100;
});
const typeLabel = useAssistantState(({ attachment }) => {
const type = attachment?.type;
switch (type) {
case "image":
return "Image";
case "document":
return "Document";
case "file":
return "File";
default:
return "File"; // Default fallback for unknown types
}
});
return (
<Tooltip>
<AttachmentPrimitive.Root
className={cn(
"aui-attachment-root relative",
isImage && "aui-attachment-root-composer only:[&>#attachment-tile]:size-24"
)}
>
<AttachmentPreviewDialog>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[14px] border bg-muted transition-opacity hover:opacity-75",
isComposer && "aui-attachment-tile-composer border-foreground/20",
isProcessing && "animate-pulse"
)}
id="attachment-tile"
aria-label={isProcessing ? "Processing attachment..." : `${typeLabel} attachment`}
>
<AttachmentThumb />
</button>
</TooltipTrigger>
</AttachmentPreviewDialog>
{isComposer && !isProcessing && <AttachmentRemove />}
</AttachmentPrimitive.Root>
<TooltipContent
side="top"
className="bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none"
>
{isProcessing ? (
<span className="flex items-center gap-1.5">
<Spinner size="xs" />
Processing...
</span>
) : (
<AttachmentPrimitive.Name />
)}
</TooltipContent>
</Tooltip>
);
};
const AttachmentRemove: FC = () => {
return (
<AttachmentPrimitive.Remove asChild>
<TooltipIconButton
tooltip="Remove file"
className="aui-attachment-tile-remove absolute top-1.5 right-1.5 size-3.5 rounded-full bg-white text-muted-foreground opacity-100 shadow-sm hover:bg-white! [&_svg]:text-black hover:[&_svg]:text-destructive"
side="top"
>
<XIcon className="aui-attachment-remove-icon size-3 dark:stroke-[2.5px]" />
</TooltipIconButton>
</AttachmentPrimitive.Remove>
);
};
/**
* Image attachment with preview thumbnail (click to expand)
*/
const MessageImageAttachment: FC = () => {
const attachmentName = useAssistantState(({ attachment }) => attachment?.name || "Image");
const src = useAttachmentSrc();
if (!src) return null;
return (
<AttachmentPreviewDialog>
<div
className="relative group cursor-pointer overflow-hidden rounded-xl border border-border/50 bg-muted transition-all hover:border-primary/30 hover:shadow-md"
title={`Click to expand: ${attachmentName}`}
>
<Image
src={src}
alt={attachmentName}
width={120}
height={90}
className="object-cover w-[120px] h-[90px] transition-transform group-hover:scale-105"
/>
{/* Hover overlay with filename */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute bottom-1.5 left-1.5 right-1.5">
<span className="text-[10px] text-white/90 font-medium truncate block">
{attachmentName}
</span>
</div>
</div>
</div>
</AttachmentPreviewDialog>
);
};
/**
* Document/file attachment as chip (similar to mentioned documents)
*/
const MessageDocumentAttachment: FC = () => {
const attachmentName = useAssistantState(({ attachment }) => attachment?.name || "Attachment");
return (
<AttachmentPreviewDialog>
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20 cursor-pointer hover:bg-primary/20 transition-colors"
title={attachmentName}
>
<FileText className="size-3" />
<span className="max-w-[150px] truncate">{attachmentName}</span>
</span>
</AttachmentPreviewDialog>
);
};
/**
* Attachment component for user messages
* Shows image preview for images, chip for documents
*/
const MessageAttachmentChip: FC = () => {
const isImage = useAssistantState(({ attachment }) => attachment?.type === "image");
if (isImage) {
return <MessageImageAttachment />;
}
return <MessageDocumentAttachment />;
};
export const UserMessageAttachments: FC = () => {
return <MessagePrimitive.Attachments components={{ Attachment: MessageAttachmentChip }} />;
};
export const ComposerAttachments: FC = () => {
return (
<div className="aui-composer-attachments mb-2 flex w-full flex-row items-center gap-2 overflow-x-auto px-1.5 pt-0.5 pb-1 empty:hidden">
<ComposerPrimitive.Attachments components={{ Attachment: AttachmentUI }} />
</div>
);
};
export const ComposerAddAttachment: FC = () => {
const chatAttachmentInputRef = useRef<HTMLInputElement>(null);
const { openDialog } = useDocumentUploadDialog();
const handleFileUpload = () => {
openDialog();
};
const handleChatAttachment = () => {
chatAttachmentInputRef.current?.click();
};
// Prevent event bubbling when file input is clicked
const handleFileInputClick = (e: React.MouseEvent<HTMLInputElement>) => {
e.stopPropagation();
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<TooltipIconButton
tooltip="Upload"
side="bottom"
variant="ghost"
size="icon"
className="aui-composer-add-attachment 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"
>
<PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" />
</TooltipIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72 bg-background border-border">
<DropdownMenuItem onSelect={handleChatAttachment} className="cursor-pointer">
<Paperclip className="size-4" />
<span>Add attachment to this chat</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleFileUpload} className="cursor-pointer">
<Upload className="size-4" />
<span>Upload documents to Search Space</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ComposerPrimitive.AddAttachment asChild>
<input
ref={chatAttachmentInputRef}
type="file"
multiple
className="hidden"
accept="image/*,application/pdf,.doc,.docx,.txt"
onClick={handleFileInputClick}
/>
</ComposerPrimitive.AddAttachment>
</>
);
};

View file

@ -122,10 +122,12 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
<div className="flex flex-col">
<span className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal wrap-break-word">
{getConnectorTypeDisplay(connector?.connector_type || "")} Connected !
</span>{" "}
<span className="text-xl sm:text-xl font-semibold text-muted-foreground tracking-tight text-wrap whitespace-normal wrap-break-word">
{getConnectorDisplayName(connector?.name || "")}
</span>
{connector?.name?.includes(" - ") && (
<span className="text-xl sm:text-xl font-semibold text-muted-foreground tracking-tight text-wrap whitespace-normal wrap-break-word">
{getConnectorDisplayName(connector.name)}
</span>
)}
</div>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Configure when to start syncing your data

View file

@ -27,6 +27,12 @@ export interface InlineMentionEditorRef {
getText: () => string;
getMentionedDocuments: () => MentionedDocument[];
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
setDocumentChipStatus: (
docId: number,
docType: string | undefined,
statusLabel: string | null,
statusKind?: "pending" | "processing" | "ready" | "failed"
) => void;
}
interface InlineMentionEditorProps {
@ -46,6 +52,7 @@ interface InlineMentionEditorProps {
const CHIP_DATA_ATTR = "data-mention-chip";
const CHIP_ID_ATTR = "data-mention-id";
const CHIP_DOCTYPE_ATTR = "data-mention-doctype";
const CHIP_STATUS_ATTR = "data-mention-status";
/**
* Type guard to check if a node is a chip element
@ -182,6 +189,11 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
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";
@ -207,6 +219,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
chip.appendChild(iconSpan);
chip.appendChild(titleSpan);
chip.appendChild(statusSpan);
chip.appendChild(removeBtn);
return chip;
@ -332,6 +345,48 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
}
}, []);
const setDocumentChipStatus = useCallback(
(
docId: number,
docType: string | undefined,
statusLabel: string | null,
statusKind: "pending" | "processing" | "ready" | "failed" = "pending"
) => {
if (!editorRef.current) return;
const chips = editorRef.current.querySelectorAll<HTMLSpanElement>(
`span[${CHIP_DATA_ATTR}="true"]`
);
for (const chip of chips) {
const chipId = getChipId(chip);
const chipType = getChipDocType(chip);
if (chipId !== docId) continue;
if ((docType ?? "UNKNOWN") !== chipType) continue;
const statusEl = chip.querySelector<HTMLSpanElement>(`span[${CHIP_STATUS_ATTR}="true"]`);
if (!statusEl) continue;
if (!statusLabel) {
statusEl.textContent = "";
statusEl.className = "text-[10px] font-semibold opacity-80 hidden";
continue;
}
const statusClass =
statusKind === "failed"
? "text-destructive"
: statusKind === "processing"
? "text-amber-700"
: statusKind === "ready"
? "text-emerald-700"
: "text-amber-700";
statusEl.textContent = statusLabel;
statusEl.className = `text-[10px] font-semibold opacity-80 ${statusClass}`;
}
},
[]
);
// Expose methods via ref
useImperativeHandle(ref, () => ({
focus: () => editorRef.current?.focus(),
@ -339,6 +394,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
getText,
getMentionedDocuments,
insertDocumentChip,
setDocumentChipStatus,
}));
// Handle input changes
@ -526,7 +582,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
className={cn(
"min-h-[24px] max-h-32 overflow-y-auto",
"text-sm outline-none",
"whitespace-pre-wrap break-words",
"whitespace-pre-wrap wrap-break-word",
disabled && "opacity-50 cursor-not-allowed",
className
)}

View file

@ -19,13 +19,15 @@ import {
ChevronRightIcon,
CopyIcon,
DownloadIcon,
Loader2,
FileWarning,
Paperclip,
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 {
@ -40,7 +42,6 @@ import {
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import {
@ -61,20 +62,35 @@ 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 { 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 */
const CYCLING_PLACEHOLDERS = [
"Ask SurfSense anything or @mention docs.",
"Generate a podcast from marketing tips in the company handbook.",
"Sum up our vacation policy from Drive.",
"Generate a podcast from my vacation ideas in Notion.",
"Sum up last week's meeting notes from Drive in a bulleted list.",
"Give me a brief overview of the most urgent tickets in Jira and Linear.",
"Create a concise table of today's top ten emails and calendar events.",
"Briefly, what are today's top ten important emails and calendar events?",
"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";
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;
@ -230,8 +246,13 @@ const Composer: FC = () => {
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
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 documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const { search_space_id, chat_id } = useParams();
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
@ -357,9 +378,28 @@ 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) {
if (
isThreadRunning ||
isBlockedByOtherUser ||
isUploadingDocs ||
blockingUploadedMentions.length > 0
) {
return;
}
if (!showDocumentPopover) {
@ -375,6 +415,8 @@ const Composer: FC = () => {
showDocumentPopover,
isThreadRunning,
isBlockedByOtherUser,
isUploadingDocs,
blockingUploadedMentions.length,
composerRuntime,
setMentionedDocuments,
setMentionedDocumentIds,
@ -395,6 +437,11 @@ const Composer: FC = () => {
});
return updated;
});
setUploadedMentionDocs((prev) => {
if (!(docId in prev)) return prev;
const { [docId]: _removed, ...rest } = prev;
return rest;
});
},
[setMentionedDocuments, setMentionedDocumentIds]
);
@ -433,6 +480,139 @@ const Composer: FC = () => {
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
);
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(() => {
uploadInputRef.current?.click();
}, []);
const handleUploadInputChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = "";
if (files.length === 0 || !search_space_id) 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
@ -441,8 +621,7 @@ const Composer: FC = () => {
currentUserId={currentUser?.id ?? null}
members={members ?? []}
/>
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
<ComposerAttachments />
<div className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow">
{/* Inline editor with @mention support */}
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
<InlineMentionEditor
@ -457,6 +636,14 @@ 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 &&
@ -483,33 +670,43 @@ const Composer: FC = () => {
/>,
document.body
)}
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
</ComposerPrimitive.AttachmentDropzone>
<ComposerAction
isBlockedByOtherUser={isBlockedByOtherUser}
onUploadClick={handleUploadClick}
isUploadingDocs={isUploadingDocs}
blockingUploadedMentionsCount={blockingUploadedMentions.length}
hasFailedUploadedMentions={blockingUploadedMentions.some(
(doc) => uploadedMentionDocs[doc.id]?.state === "failed"
)}
/>
</div>
</ComposerPrimitive.Root>
);
};
interface ComposerActionProps {
isBlockedByOtherUser?: boolean;
onUploadClick: () => void;
isUploadingDocs: boolean;
blockingUploadedMentionsCount: number;
hasFailedUploadedMentions: boolean;
}
const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
// Check if any attachments are still being processed (running AND progress < 100)
// When progress is 100, processing is done but waiting for send()
const hasProcessingAttachments = useAssistantState(({ composer }) =>
composer.attachments?.some((att) => {
const status = att.status;
if (status?.type !== "running") return false;
const progress = (status as { type: "running"; progress?: number }).progress;
return progress === undefined || progress < 100;
})
);
const ComposerAction: FC<ComposerActionProps> = ({
isBlockedByOtherUser = false,
onUploadClick,
isUploadingDocs,
blockingUploadedMentionsCount,
hasFailedUploadedMentions,
}) => {
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
// Check if composer text is empty
const isComposerEmpty = useAssistantState(({ composer }) => {
// 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);
@ -530,25 +727,47 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
}, [preferences, globalConfigs, userConfigs]);
const isSendDisabled =
hasProcessingAttachments || isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
isComposerEmpty ||
!hasModelConfigured ||
isBlockedByOtherUser ||
isUploadingDocs ||
blockingUploadedMentionsCount > 0;
return (
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
<div className="flex items-center gap-1">
<ComposerAddAttachment />
<TooltipIconButton
tooltip={isUploadingDocs ? "Uploading documents..." : "Upload and mention files"}
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}
>
{isUploadingDocs ? (
<Spinner size="sm" className="text-muted-foreground" />
) : (
<Paperclip className="size-4" />
)}
</TooltipIconButton>
<ConnectorIndicator />
</div>
{/* Show processing indicator when attachments are being processed */}
{hasProcessingAttachments && (
{blockingUploadedMentionsCount > 0 && (
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<Loader2 className="size-3 animate-spin" />
<span>Processing...</span>
{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 && !hasProcessingAttachments && (
{!hasModelConfigured && blockingUploadedMentionsCount === 0 && (
<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>
@ -561,13 +780,17 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
tooltip={
isBlockedByOtherUser
? "Wait for AI to finish responding"
: !hasModelConfigured
? "Please select a model from the header to start chatting"
: hasProcessingAttachments
? "Wait for attachments to process"
: isComposerEmpty
? "Enter a message to send"
: "Send message"
: 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"

View file

@ -3,7 +3,6 @@ import { useAtomValue } from "jotai";
import { FileText, PencilIcon } from "lucide-react";
import { type FC, useState } from "react";
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
interface AuthorMetadata {
@ -48,9 +47,6 @@ export const UserMessage: FC = () => {
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
const metadata = useAssistantState(({ message }) => message?.metadata);
const author = metadata?.custom?.author as AuthorMetadata | undefined;
const hasAttachments = useAssistantState(
({ message }) => message?.attachments && message.attachments.length > 0
);
return (
<MessagePrimitive.Root
@ -59,11 +55,9 @@ export const UserMessage: FC = () => {
>
<div className="aui-user-message-content-wrapper col-start-2 min-w-0 flex items-end gap-2">
<div className="flex-1 min-w-0">
{/* Display attachments and mentioned documents */}
{(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && (
{/* Display mentioned documents */}
{mentionedDocs && mentionedDocs.length > 0 && (
<div className="flex flex-wrap items-end gap-2 mb-2 justify-end">
{/* Attachments (images show as thumbnails, documents as chips) */}
<UserMessageAttachments />
{/* Mentioned documents as chips */}
{mentionedDocs?.map((doc) => (
<span

View file

@ -43,7 +43,7 @@ export function FooterNew() {
},
{
title: "LinkedIn",
href: "https://www.linkedin.com/in/rohan-verma-sde/",
href: "https://www.linkedin.com/company/surfsense/",
icon: IconBrandLinkedin,
},
{

View file

@ -34,8 +34,8 @@ const GoogleLogo = ({ className }: { className?: string }) => (
export function HeroSection() {
const containerRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null);
const heroVariant = useFeatureFlagVariantKey("notebooklm_flag");
const isNotebookLMVariant = heroVariant === "notebooklm";
const heroVariant = useFeatureFlagVariantKey("notebooklm_superpowers_flag");
const isNotebookLMVariant = heroVariant === "superpowers";
return (
<div
@ -89,25 +89,24 @@ export function HeroSection() {
{isNotebookLMVariant ? (
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
<span className="">NotebookLM for Teams</span>
<span className="">NotebookLM with Superpowers</span>
</div>
</div>
) : (
<>
The AI Workspace{" "}
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
<span className="">Built for Teams</span>
</div>
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
<span className="">NotebookLM for Teams</span>
</div>
</>
</div>
)}
</Balancer>
</h2>
{/* // TODO:aCTUAL DESCRITION */}
<p className="relative z-50 mx-auto mt-4 max-w-lg px-4 text-center text-base/6 text-gray-600 dark:text-gray-200">
Connect any LLM to your internal knowledge sources and chat with it in real time alongside
your team.
Connect any AI to your documents and knowledge sources.
</p>
<p className="relative z-50 mx-auto mt-0 max-w-lg px-4 text-center text-base/6 text-gray-600 dark:text-gray-200">
Then chat with it in real-time, even alongside your team.
</p>
<div className="mb-10 mt-8 flex w-full flex-col items-center justify-center gap-4 px-8 sm:flex-row md:mb-20">
<GetStartedButton />

View file

@ -1,5 +1,7 @@
"use client";
import React, { useEffect, useState } from "react";
import type React from "react";
import Image from "next/image";
interface Integration {
name: string;
@ -8,181 +10,210 @@ interface Integration {
const INTEGRATIONS: Integration[] = [
// Search
{ name: "Tavily", icon: "https://www.tavily.com/images/logo.svg" },
{
name: "LinkUp",
icon: "https://framerusercontent.com/images/7zeIm6t3f1HaSltkw8upEvsD80.png?scale-down-to=512",
},
{ name: "Elasticsearch", icon: "https://cdn.simpleicons.org/elastic/00A9E5" },
{ name: "Tavily", icon: "/connectors/tavily.svg" },
{ name: "Elasticsearch", icon: "/connectors/elasticsearch.svg" },
{ name: "Baidu Search", icon: "/connectors/baidu-search.svg" },
{ name: "SearXNG", icon: "/connectors/searxng.svg" },
// Communication
{
name: "Slack",
icon: "https://upload.wikimedia.org/wikipedia/commons/d/d5/Slack_icon_2019.svg",
},
{ name: "Discord", icon: "https://cdn.simpleicons.org/discord/5865F2" },
{ name: "Gmail", icon: "https://cdn.simpleicons.org/gmail/EA4335" },
{ name: "Slack", icon: "/connectors/slack.svg" },
{ name: "Discord", icon: "/connectors/discord.svg" },
{ name: "Gmail", icon: "/connectors/google-gmail.svg" },
{ name: "Microsoft Teams", icon: "/connectors/microsoft-teams.svg" },
// Project Management
{ name: "Linear", icon: "https://cdn.simpleicons.org/linear/5E6AD2" },
{ name: "Jira", icon: "https://cdn.simpleicons.org/jira/0052CC" },
{ name: "ClickUp", icon: "https://cdn.simpleicons.org/clickup/7B68EE" },
{ name: "Airtable", icon: "https://cdn.simpleicons.org/airtable/18BFFF" },
{ name: "Linear", icon: "/connectors/linear.svg" },
{ name: "Jira", icon: "/connectors/jira.svg" },
{ name: "ClickUp", icon: "/connectors/clickup.svg" },
{ name: "Airtable", icon: "/connectors/airtable.svg" },
// Documentation & Knowledge
{ name: "Confluence", icon: "https://cdn.simpleicons.org/confluence/172B4D" },
{ name: "Notion", icon: "https://cdn.simpleicons.org/notion/000000/ffffff" },
{ name: "Web Pages", icon: "https://cdn.jsdelivr.net/npm/lucide-static@0.294.0/icons/globe.svg" },
{ name: "Confluence", icon: "/connectors/confluence.svg" },
{ name: "Notion", icon: "/connectors/notion.svg" },
{ name: "BookStack", icon: "/connectors/bookstack.svg" },
{ name: "Obsidian", icon: "/connectors/obsidian.svg" },
// Cloud Storage
{ name: "Google Drive", icon: "https://cdn.simpleicons.org/googledrive/4285F4" },
{ name: "Dropbox", icon: "https://cdn.simpleicons.org/dropbox/0061FF" },
{
name: "Amazon S3",
icon: "https://upload.wikimedia.org/wikipedia/commons/b/bc/Amazon-S3-Logo.svg",
},
{ name: "Google Drive", icon: "/connectors/google-drive.svg" },
// Development
{ name: "GitHub", icon: "https://cdn.simpleicons.org/github/181717/ffffff" },
{ name: "GitHub", icon: "/connectors/github.svg" },
// Productivity
{ name: "Google Calendar", icon: "https://cdn.simpleicons.org/googlecalendar/4285F4" },
{ name: "Luma", icon: "https://images.lumacdn.com/social-images/default-social-202407.png" },
{ name: "Google Calendar", icon: "/connectors/google-calendar.svg" },
{ name: "Luma", icon: "/connectors/luma.svg" },
// Media
{ name: "YouTube", icon: "https://cdn.simpleicons.org/youtube/FF0000" },
{ name: "YouTube", icon: "/connectors/youtube.svg" },
// Search
{ name: "Linkup", icon: "/connectors/linkup.svg" },
// Meetings
{ name: "Circleback", icon: "/connectors/circleback.svg" },
// AI
{ name: "MCP", icon: "/connectors/modelcontextprotocol.svg" },
];
function SemiCircleOrbit({ radius, centerX, centerY, count, iconSize, startIndex }: any) {
// 5 vertical columns — 23 icons spread across categories
const COLUMNS: number[][] = [
[2, 5, 10, 0, 21, 11],
[1, 7, 20, 17],
[13, 6, 23, 4, 16],
[12, 8, 15, 18],
[3, 9, 14, 22, 19],
];
// Different scroll speeds per column for organic feel (seconds)
const SCROLL_DURATIONS = [26, 32, 22, 30, 28];
function IntegrationCard({ integration }: { integration: Integration }) {
return (
<>
{/* Semi-circle glow background */}
<div className="absolute inset-0 flex justify-center items-start overflow-visible">
<div
className="
w-[800px] h-[800px] rounded-full
bg-[radial-gradient(circle_at_center,rgba(0,0,0,0.15),transparent_70%)]
dark:bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.15),transparent_70%)]
blur-3xl
pointer-events-none
"
style={{
zIndex: 0,
transform: "translateY(-20%)",
}}
/>
<div
className="w-[60px] h-[60px] sm:w-[80px] sm:h-[80px] md:w-[120px] md:h-[120px] lg:w-[140px] lg:h-[140px] rounded-[16px] sm:rounded-[20px] md:rounded-[24px] flex items-center justify-center shrink-0 select-none"
style={{
background: "linear-gradient(145deg, var(--card-from), var(--card-to))",
boxShadow: "inset 0 1px 0 0 var(--card-highlight), 0 4px 24px var(--card-shadow)",
}}
>
<Image
src={integration.icon}
alt={integration.name}
className="w-6 h-6 sm:w-7 sm:h-7 md:w-10 md:h-10 lg:w-12 lg:h-12 object-contain select-none pointer-events-none"
loading="lazy"
draggable={false}
width={48}
height={48}
/>
</div>
);
}
function ScrollingColumn({
cards,
scrollUp,
duration,
colIndex,
isEdge,
isEdgeAdjacent,
}: {
cards: number[];
scrollUp: boolean;
duration: number;
colIndex: number;
isEdge: boolean;
isEdgeAdjacent: boolean;
}) {
// Edge columns get a heavy vertical mask; edge-adjacent columns get a lighter one to smooth the transition
const columnMask = isEdge
? {
maskImage:
"linear-gradient(to bottom, transparent 0%, transparent 20%, black 40%, black 60%, transparent 80%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, transparent 0%, transparent 20%, black 40%, black 60%, transparent 80%, transparent 100%)",
}
: isEdgeAdjacent
? {
maskImage:
"linear-gradient(to bottom, transparent 0%, transparent 10%, black 30%, black 70%, transparent 90%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, transparent 0%, transparent 10%, black 30%, black 70%, transparent 90%, transparent 100%)",
}
: {};
const cardSet = cards.map((integrationIndex, i) => (
<IntegrationCard
key={`${INTEGRATIONS[integrationIndex].name}-c${colIndex}-${i}`}
integration={INTEGRATIONS[integrationIndex]}
/>
));
return (
<div
className="flex-shrink-0 overflow-hidden"
style={{ ...columnMask, contain: "layout style paint" }}
>
{/* Outer div has NO gap — each inner copy uses pb matching the gap so both halves are identical in height → seamless -50% loop */}
<div
className="flex flex-col"
style={{
animation: `${scrollUp ? "integrations-scroll-up" : "integrations-scroll-down"} ${duration}s linear infinite`,
willChange: "transform",
transform: "translateZ(0)",
}}
>
<div className="flex flex-col gap-2 sm:gap-3 md:gap-5 lg:gap-6 pb-2 sm:pb-3 md:pb-5 lg:pb-6">
{cardSet}
</div>
<div className="flex flex-col gap-2 sm:gap-3 md:gap-5 lg:gap-6 pb-2 sm:pb-3 md:pb-5 lg:pb-6">
{cardSet}
</div>
</div>
{/* Orbit icons */}
{Array.from({ length: count }).map((_, index) => {
const actualIndex = startIndex + index;
// Skip if we've run out of integrations
if (actualIndex >= INTEGRATIONS.length) return null;
const angle = (index / (count - 1)) * 180;
const x = radius * Math.cos((angle * Math.PI) / 180);
const y = radius * Math.sin((angle * Math.PI) / 180);
const integration = INTEGRATIONS[actualIndex];
// Tooltip positioning — above or below based on angle
const tooltipAbove = angle > 90;
return (
<div
key={index}
className="absolute flex flex-col items-center group"
style={{
left: `${centerX + x - iconSize / 2}px`,
top: `${centerY - y - iconSize / 2}px`,
zIndex: 5,
}}
>
<img
src={integration.icon}
alt={integration.name}
width={iconSize}
height={iconSize}
className="object-contain cursor-pointer transition-transform hover:scale-110"
style={{ minWidth: iconSize, minHeight: iconSize }} // fix accidental shrink
/>
{/* Tooltip */}
<div
className={`absolute ${
tooltipAbove ? "bottom-[calc(100%+8px)]" : "top-[calc(100%+8px)]"
} hidden group-hover:block w-auto min-w-max rounded-lg bg-black px-3 py-1.5 text-xs text-white shadow-lg text-center whitespace-nowrap`}
>
{integration.name}
<div
className={`absolute left-1/2 -translate-x-1/2 w-3 h-3 rotate-45 bg-black ${
tooltipAbove ? "top-full" : "bottom-full"
}`}
></div>
</div>
</div>
);
})}
</>
</div>
);
}
export default function ExternalIntegrations() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const updateSize = () => setSize({ width: window.innerWidth, height: window.innerHeight });
updateSize();
window.addEventListener("resize", updateSize);
return () => window.removeEventListener("resize", updateSize);
}, []);
const baseWidth = Math.min(size.width * 0.8, 700);
const centerX = baseWidth / 2;
const centerY = baseWidth * 0.5;
const iconSize =
size.width < 480
? Math.max(24, baseWidth * 0.05)
: size.width < 768
? Math.max(28, baseWidth * 0.06)
: Math.max(32, baseWidth * 0.07);
return (
<section className="py-12 relative min-h-screen w-full overflow-visible">
<div className="relative flex flex-col items-center text-center z-10">
<h1 className="my-6 text-4xl font-bold lg:text-7xl">Integrations</h1>
<p className="mb-12 max-w-2xl text-gray-600 dark:text-gray-400 lg:text-xl">
Integrate with your team's most important tools
</p>
<section
className={[
"relative py-20 md:py-28 overflow-hidden",
// No explicit background — inherits the page gradient for seamless blending
// CSS custom properties — light mode (card styling)
"[--card-from:rgba(255,255,255,0.9)]",
"[--card-to:rgba(245,245,248,0.92)]",
"[--card-highlight:rgba(255,255,255,0.5)]",
"[--card-lowlight:transparent]",
"[--card-shadow:transparent]",
"[--card-border:transparent]",
// CSS custom properties — dark mode (card styling)
"dark:[--card-from:rgb(28,28,32)]",
"dark:[--card-to:rgb(28,28,32)]",
"dark:[--card-highlight:rgba(255,255,255,0.03)]",
"dark:[--card-lowlight:rgba(0,0,0,0.1)]",
"dark:[--card-shadow:rgba(0,0,0,0.15)]",
"dark:[--card-border:rgba(255,255,255,0.03)]",
].join(" ")}
>
{/* Heading */}
<div className="text-center mb-12 md:mb-16 relative z-20 px-4">
<h3 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-white leading-[1.1] tracking-tight">
Integrate with your
<br />
team&apos;s most important tools
</h3>
</div>
<div
className="relative overflow-visible"
style={{ width: baseWidth, height: baseWidth * 0.7, paddingBottom: "100px" }}
>
<SemiCircleOrbit
radius={baseWidth * 0.22}
centerX={centerX}
centerY={centerY}
count={5}
iconSize={iconSize}
startIndex={0}
/>
<SemiCircleOrbit
radius={baseWidth * 0.36}
centerX={centerX}
centerY={centerY}
count={6}
iconSize={iconSize}
startIndex={5}
/>
<SemiCircleOrbit
radius={baseWidth * 0.5}
centerX={centerX}
centerY={centerY}
count={8}
iconSize={iconSize}
startIndex={11}
/>
{/* Scrolling columns container — masked at edges so the page background shows through seamlessly */}
<div
className="relative"
style={
{
maskImage:
"linear-gradient(to bottom, transparent 0%, black 25%, black 70%, transparent 100%), " +
"linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%), " +
"linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
maskComposite: "intersect",
WebkitMaskComposite: "source-in",
} as React.CSSProperties
}
>
{/* 5 scrolling columns */}
<div className="flex justify-center gap-2 sm:gap-3 md:gap-5 lg:gap-6 h-[340px] sm:h-[420px] md:h-[560px] lg:h-[640px] overflow-hidden">
{COLUMNS.map((column, colIndex) => (
<ScrollingColumn
key={`col-${SCROLL_DURATIONS[colIndex]}-${colIndex}`}
cards={column}
scrollUp={colIndex % 2 === 0}
duration={SCROLL_DURATIONS[colIndex]}
colIndex={colIndex}
isEdge={colIndex === 0 || colIndex === COLUMNS.length - 1}
isEdgeAdjacent={colIndex === 1 || colIndex === COLUMNS.length - 2}
/>
))}
</div>
</div>
</section>

View file

@ -1,4 +1,4 @@
import { FileJson, Loader2 } from "lucide-react";
import { FileJson } from "lucide-react";
import React from "react";
import { defaultStyles, JsonView } from "react-json-view-lite";
import { Button } from "@/components/ui/button";
@ -9,6 +9,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import "react-json-view-lite/dist/index.css";
interface JsonMetadataViewerProps {
@ -58,7 +59,7 @@ export function JsonMetadataViewer({
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<Spinner size="lg" className="text-muted-foreground" />
</div>
) : (
<JsonView data={jsonData} style={defaultStyles} />

View file

@ -6,6 +6,7 @@ interface SidebarContextValue {
isCollapsed: boolean;
setIsCollapsed: (collapsed: boolean) => void;
toggleCollapsed: () => void;
sidebarWidth: number;
}
const SidebarContext = createContext<SidebarContextValue | null>(null);

View file

@ -0,0 +1,101 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
const SIDEBAR_WIDTH_COOKIE_NAME = "sidebar_width";
const SIDEBAR_WIDTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
export const SIDEBAR_MIN_WIDTH = 240;
export const SIDEBAR_MAX_WIDTH = 480;
interface UseSidebarResizeReturn {
sidebarWidth: number;
handleMouseDown: (e: React.MouseEvent) => void;
isDragging: boolean;
}
export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarResizeReturn {
const [sidebarWidth, setSidebarWidth] = useState(defaultWidth);
const [isDragging, setIsDragging] = useState(false);
const startXRef = useRef(0);
const startWidthRef = useRef(defaultWidth);
// Initialize from cookie on mount
useEffect(() => {
try {
const match = document.cookie.match(/(?:^|; )sidebar_width=([^;]+)/);
if (match) {
const parsed = Number(match[1]);
if (!Number.isNaN(parsed) && parsed >= SIDEBAR_MIN_WIDTH && parsed <= SIDEBAR_MAX_WIDTH) {
setSidebarWidth(parsed);
}
}
} catch {
// Ignore cookie read errors
}
}, []);
// Persist width to cookie
const persistWidth = useCallback((width: number) => {
try {
document.cookie = `${SIDEBAR_WIDTH_COOKIE_NAME}=${width}; path=/; max-age=${SIDEBAR_WIDTH_COOKIE_MAX_AGE}`;
} catch {
// Ignore cookie write errors
}
}, []);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
startXRef.current = e.clientX;
startWidthRef.current = sidebarWidth;
setIsDragging(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
},
[sidebarWidth]
);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const delta = e.clientX - startXRef.current;
const newWidth = Math.min(
SIDEBAR_MAX_WIDTH,
Math.max(SIDEBAR_MIN_WIDTH, startWidthRef.current + delta)
);
setSidebarWidth(newWidth);
};
const handleMouseUp = () => {
setIsDragging(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
// Persist the final width
setSidebarWidth((currentWidth) => {
persistWidth(currentWidth);
return currentWidth;
});
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [isDragging, persistWidth]);
return {
sidebarWidth,
handleMouseDown,
isDragging,
};
}

View file

@ -25,16 +25,14 @@ import { Input } from "@/components/ui/input";
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
import { logout } from "@/lib/auth-utils";
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { LayoutShell } from "../ui/shell";
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
interface LayoutDataProviderProps {
searchSpaceId: string;
@ -390,7 +388,13 @@ export function LayoutDataProvider({
(item: NavItem) => {
// Handle inbox specially - toggle sidebar instead of navigating
if (item.url === "#inbox") {
setIsInboxSidebarOpen((prev) => !prev);
setIsInboxSidebarOpen((prev) => {
if (!prev) {
setIsAllSharedChatsSidebarOpen(false);
setIsAllPrivateChatsSidebarOpen(false);
}
return !prev;
});
return;
}
router.push(item.url);
@ -490,10 +494,14 @@ export function LayoutDataProvider({
const handleViewAllSharedChats = useCallback(() => {
setIsAllSharedChatsSidebarOpen(true);
setIsAllPrivateChatsSidebarOpen(false);
setIsInboxSidebarOpen(false);
}, []);
const handleViewAllPrivateChats = useCallback(() => {
setIsAllPrivateChatsSidebarOpen(true);
setIsAllSharedChatsSidebarOpen(false);
setIsInboxSidebarOpen(false);
}, []);
// Delete handlers
@ -614,6 +622,16 @@ export function LayoutDataProvider({
isDocked: isInboxDocked,
onDockedChange: setIsInboxDocked,
}}
allSharedChatsPanel={{
open: isAllSharedChatsSidebarOpen,
onOpenChange: setIsAllSharedChatsSidebarOpen,
searchSpaceId,
}}
allPrivateChatsPanel={{
open: isAllPrivateChatsSidebarOpen,
onOpenChange: setIsAllPrivateChatsSidebarOpen,
searchSpaceId,
}}
>
{children}
</LayoutShell>
@ -796,20 +814,6 @@ export function LayoutDataProvider({
</DialogContent>
</Dialog>
{/* All Shared Chats Sidebar */}
<AllSharedChatsSidebar
open={isAllSharedChatsSidebarOpen}
onOpenChange={setIsAllSharedChatsSidebarOpen}
searchSpaceId={searchSpaceId}
/>
{/* All Private Chats Sidebar */}
<AllPrivateChatsSidebar
open={isAllPrivateChatsSidebarOpen}
onOpenChange={setIsAllPrivateChatsSidebarOpen}
searchSpaceId={searchSpaceId}
/>
{/* Create Search Space Dialog */}
<CreateSearchSpaceDialog
open={isCreateSearchSpaceDialogOpen}

View file

@ -1,8 +1,8 @@
"use client";
import { Settings, Trash2, Users } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react";
import {
ContextMenu,
ContextMenuContent,

View file

@ -6,10 +6,18 @@ import type { InboxItem } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { SidebarProvider, useSidebarState } from "../../hooks";
import { useSidebarResize } from "../../hooks/useSidebarResize";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { Header } from "../header";
import { IconRail } from "../icon-rail";
import { InboxSidebar, MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
import {
AllPrivateChatsSidebar,
AllSharedChatsSidebar,
InboxSidebar,
MobileSidebar,
MobileSidebarTrigger,
Sidebar,
} from "../sidebar";
// Tab-specific data source props
interface TabDataSource {
@ -75,6 +83,17 @@ interface LayoutShellProps {
// Inbox props
inbox?: InboxProps;
isLoadingChats?: boolean;
// All chats panel props
allSharedChatsPanel?: {
open: boolean;
onOpenChange: (open: boolean) => void;
searchSpaceId: string;
};
allPrivateChatsPanel?: {
open: boolean;
onOpenChange: (open: boolean) => void;
searchSpaceId: string;
};
}
export function LayoutShell({
@ -112,15 +131,22 @@ export function LayoutShell({
className,
inbox,
isLoadingChats = false,
allSharedChatsPanel,
allPrivateChatsPanel,
}: LayoutShellProps) {
const isMobile = useIsMobile();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
const {
sidebarWidth,
handleMouseDown: onResizeMouseDown,
isDragging: isResizing,
} = useSidebarResize();
// Memoize context value to prevent unnecessary re-renders
const sidebarContextValue = useMemo(
() => ({ isCollapsed, setIsCollapsed, toggleCollapsed }),
[isCollapsed, setIsCollapsed, toggleCollapsed]
() => ({ isCollapsed, setIsCollapsed, toggleCollapsed, sidebarWidth }),
[isCollapsed, setIsCollapsed, toggleCollapsed, sidebarWidth]
);
// Mobile layout
@ -236,6 +262,9 @@ export function LayoutShell({
setTheme={setTheme}
className="hidden md:flex border-r shrink-0"
isLoadingChats={isLoadingChats}
sidebarWidth={sidebarWidth}
onResizeMouseDown={onResizeMouseDown}
isResizing={isResizing}
/>
{/* Docked Inbox Sidebar - renders as flex sibling between sidebar and content */}
@ -275,6 +304,24 @@ export function LayoutShell({
onDockedChange={inbox.onDockedChange}
/>
)}
{/* All Shared Chats - slide-out panel */}
{allSharedChatsPanel && (
<AllSharedChatsSidebar
open={allSharedChatsPanel.open}
onOpenChange={allSharedChatsPanel.onOpenChange}
searchSpaceId={allSharedChatsPanel.searchSpaceId}
/>
)}
{/* All Private Chats - slide-out panel */}
{allPrivateChatsPanel && (
<AllPrivateChatsSidebar
open={allPrivateChatsPanel.open}
onOpenChange={allPrivateChatsPanel.onOpenChange}
searchSpaceId={allPrivateChatsPanel.searchSpaceId}
/>
)}
</div>
</div>
</TooltipProvider>

View file

@ -12,11 +12,9 @@ import {
User,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@ -40,6 +38,7 @@ import {
updateThread,
} from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
interface AllPrivateChatsSidebarProps {
open: boolean;
@ -69,16 +68,11 @@ export function AllPrivateChatsSidebar({
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [mounted, setMounted] = useState(false);
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
const isSearchMode = !!debouncedSearchQuery.trim();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
@ -89,17 +83,6 @@ export function AllPrivateChatsSidebar({
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
const {
data: threadsData,
error: threadsError,
@ -214,248 +197,221 @@ export function AllPrivateChatsSidebar({
const activeCount = activeChats.length;
const archivedCount = archivedChats.length;
if (!mounted) return null;
return (
<SidebarSlideOutPanel
open={open}
onOpenChange={onOpenChange}
ariaLabel={t("chats") || "Private Chats"}
>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
</div>
return createPortal(
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-70 bg-black/50"
onClick={() => onOpenChange(false)}
aria-hidden="true"
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_chats") || "Search chats..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClearSearch}
>
<X className="h-3.5 w-3.5" />
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
</Button>
)}
</div>
</div>
<motion.div
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
role="dialog"
aria-modal="true"
aria-label={t("chats") || "Private Chats"}
>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_chats") || "Search chats..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClearSearch}
>
<X className="h-3.5 w-3.5" />
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
</Button>
)}
</div>
</div>
{!isSearchMode && (
<Tabs
value={showArchived ? "archived" : "active"}
onValueChange={(value) => setShowArchived(value === "archived")}
className="shrink-0 mx-4"
>
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<TabsTrigger
value="active"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<MessageCircleMore className="h-4 w-4" />
<span>Active</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">
{activeCount}
</span>
</span>
</TabsTrigger>
<TabsTrigger
value="archived"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<ArchiveIcon className="h-4 w-4" />
<span>Archived</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">
{archivedCount}
</span>
</span>
</TabsTrigger>
</TabsList>
</Tabs>
)}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? (
<div className="space-y-1">
{[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
<div
key={`skeleton-${i}`}
className="flex items-center gap-2 rounded-md px-2 py-1.5"
>
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
</div>
))}
</div>
) : error ? (
<div className="text-center py-8 text-sm text-destructive">
{t("error_loading_chats") || "Error loading chats"}
</div>
) : threads.length > 0 ? (
<div className="space-y-1">
{threads.map((thread) => {
const isDeleting = deletingThreadId === thread.id;
const isArchiving = archivingThreadId === thread.id;
const isBusy = isDeleting || isArchiving;
const isActive = currentChatId === thread.id;
return (
<div
key={thread.id}
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
{isMobile ? (
<button
type="button"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p>
{t("updated") || "Updated"}:{" "}
{format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
</p>
</TooltipContent>
</Tooltip>
)}
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0",
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
"transition-opacity"
)}
disabled={isBusy}
>
{isDeleting ? (
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80">
<DropdownMenuItem
onClick={() => handleToggleArchive(thread.id, thread.archived)}
disabled={isArchiving}
>
{thread.archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>
</>
) : (
<>
<ArchiveIcon className="mr-2 h-4 w-4" />
<span>{t("archive") || "Archive"}</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDeleteThread(thread.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_chats_found") || "No chats found"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
<User className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{showArchived
? t("no_archived_chats") || "No archived chats"
: t("no_chats") || "No private chats"}
</p>
{!showArchived && (
<p className="text-xs text-muted-foreground/70 mt-1">
{t("start_new_chat_hint") || "Start a new chat from the chat page"}
</p>
)}
</div>
)}
</div>
</motion.div>
</>
{!isSearchMode && (
<Tabs
value={showArchived ? "archived" : "active"}
onValueChange={(value) => setShowArchived(value === "archived")}
className="shrink-0 mx-4"
>
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<TabsTrigger
value="active"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<MessageCircleMore className="h-4 w-4" />
<span>Active</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">
{activeCount}
</span>
</span>
</TabsTrigger>
<TabsTrigger
value="archived"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<ArchiveIcon className="h-4 w-4" />
<span>Archived</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">
{archivedCount}
</span>
</span>
</TabsTrigger>
</TabsList>
</Tabs>
)}
</AnimatePresence>,
document.body
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? (
<div className="space-y-1">
{[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
<div key={`skeleton-${i}`} className="flex items-center gap-2 rounded-md px-2 py-1.5">
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
</div>
))}
</div>
) : error ? (
<div className="text-center py-8 text-sm text-destructive">
{t("error_loading_chats") || "Error loading chats"}
</div>
) : threads.length > 0 ? (
<div className="space-y-1">
{threads.map((thread) => {
const isDeleting = deletingThreadId === thread.id;
const isArchiving = archivingThreadId === thread.id;
const isBusy = isDeleting || isArchiving;
const isActive = currentChatId === thread.id;
return (
<div
key={thread.id}
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
{isMobile ? (
<button
type="button"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p>
{t("updated") || "Updated"}:{" "}
{format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
</p>
</TooltipContent>
</Tooltip>
)}
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0",
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
"transition-opacity"
)}
disabled={isBusy}
>
{isDeleting ? (
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80">
<DropdownMenuItem
onClick={() => handleToggleArchive(thread.id, thread.archived)}
disabled={isArchiving}
>
{thread.archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>
</>
) : (
<>
<ArchiveIcon className="mr-2 h-4 w-4" />
<span>{t("archive") || "Archive"}</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDeleteThread(thread.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_chats_found") || "No chats found"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
<User className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{showArchived
? t("no_archived_chats") || "No archived chats"
: t("no_chats") || "No private chats"}
</p>
{!showArchived && (
<p className="text-xs text-muted-foreground/70 mt-1">
{t("start_new_chat_hint") || "Start a new chat from the chat page"}
</p>
)}
</div>
)}
</div>
</SidebarSlideOutPanel>
);
}

View file

@ -12,11 +12,9 @@ import {
Users,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@ -40,6 +38,7 @@ import {
updateThread,
} from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
interface AllSharedChatsSidebarProps {
open: boolean;
@ -69,16 +68,11 @@ export function AllSharedChatsSidebar({
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [mounted, setMounted] = useState(false);
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
const isSearchMode = !!debouncedSearchQuery.trim();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
@ -89,17 +83,6 @@ export function AllSharedChatsSidebar({
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
const {
data: threadsData,
error: threadsError,
@ -214,248 +197,221 @@ export function AllSharedChatsSidebar({
const activeCount = activeChats.length;
const archivedCount = archivedChats.length;
if (!mounted) return null;
return (
<SidebarSlideOutPanel
open={open}
onOpenChange={onOpenChange}
ariaLabel={t("shared_chats") || "Shared Chats"}
>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
</div>
return createPortal(
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-70 bg-black/50"
onClick={() => onOpenChange(false)}
aria-hidden="true"
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_chats") || "Search chats..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClearSearch}
>
<X className="h-3.5 w-3.5" />
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
</Button>
)}
</div>
</div>
<motion.div
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
role="dialog"
aria-modal="true"
aria-label={t("shared_chats") || "Shared Chats"}
>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_chats") || "Search chats..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClearSearch}
>
<X className="h-3.5 w-3.5" />
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
</Button>
)}
</div>
</div>
{!isSearchMode && (
<Tabs
value={showArchived ? "archived" : "active"}
onValueChange={(value) => setShowArchived(value === "archived")}
className="shrink-0 mx-4"
>
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<TabsTrigger
value="active"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<MessageCircleMore className="h-4 w-4" />
<span>Active</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">
{activeCount}
</span>
</span>
</TabsTrigger>
<TabsTrigger
value="archived"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<ArchiveIcon className="h-4 w-4" />
<span>Archived</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">
{archivedCount}
</span>
</span>
</TabsTrigger>
</TabsList>
</Tabs>
)}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? (
<div className="space-y-1">
{[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
<div
key={`skeleton-${i}`}
className="flex items-center gap-2 rounded-md px-2 py-1.5"
>
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
</div>
))}
</div>
) : error ? (
<div className="text-center py-8 text-sm text-destructive">
{t("error_loading_chats") || "Error loading chats"}
</div>
) : threads.length > 0 ? (
<div className="space-y-1">
{threads.map((thread) => {
const isDeleting = deletingThreadId === thread.id;
const isArchiving = archivingThreadId === thread.id;
const isBusy = isDeleting || isArchiving;
const isActive = currentChatId === thread.id;
return (
<div
key={thread.id}
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
{isMobile ? (
<button
type="button"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p>
{t("updated") || "Updated"}:{" "}
{format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
</p>
</TooltipContent>
</Tooltip>
)}
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0",
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
"transition-opacity"
)}
disabled={isBusy}
>
{isDeleting ? (
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80">
<DropdownMenuItem
onClick={() => handleToggleArchive(thread.id, thread.archived)}
disabled={isArchiving}
>
{thread.archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>
</>
) : (
<>
<ArchiveIcon className="mr-2 h-4 w-4" />
<span>{t("archive") || "Archive"}</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDeleteThread(thread.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_chats_found") || "No chats found"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
<Users className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{showArchived
? t("no_archived_chats") || "No archived chats"
: t("no_shared_chats") || "No shared chats"}
</p>
{!showArchived && (
<p className="text-xs text-muted-foreground/70 mt-1">
Share a chat to collaborate with your team
</p>
)}
</div>
)}
</div>
</motion.div>
</>
{!isSearchMode && (
<Tabs
value={showArchived ? "archived" : "active"}
onValueChange={(value) => setShowArchived(value === "archived")}
className="shrink-0 mx-4"
>
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<TabsTrigger
value="active"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<MessageCircleMore className="h-4 w-4" />
<span>Active</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">
{activeCount}
</span>
</span>
</TabsTrigger>
<TabsTrigger
value="archived"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<ArchiveIcon className="h-4 w-4" />
<span>Archived</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">
{archivedCount}
</span>
</span>
</TabsTrigger>
</TabsList>
</Tabs>
)}
</AnimatePresence>,
document.body
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? (
<div className="space-y-1">
{[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
<div key={`skeleton-${i}`} className="flex items-center gap-2 rounded-md px-2 py-1.5">
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
</div>
))}
</div>
) : error ? (
<div className="text-center py-8 text-sm text-destructive">
{t("error_loading_chats") || "Error loading chats"}
</div>
) : threads.length > 0 ? (
<div className="space-y-1">
{threads.map((thread) => {
const isDeleting = deletingThreadId === thread.id;
const isArchiving = archivingThreadId === thread.id;
const isBusy = isDeleting || isArchiving;
const isActive = currentChatId === thread.id;
return (
<div
key={thread.id}
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
{isMobile ? (
<button
type="button"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p>
{t("updated") || "Updated"}:{" "}
{format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
</p>
</TooltipContent>
</Tooltip>
)}
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0",
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
"transition-opacity"
)}
disabled={isBusy}
>
{isDeleting ? (
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80">
<DropdownMenuItem
onClick={() => handleToggleArchive(thread.id, thread.archived)}
disabled={isArchiving}
>
{thread.archived ? (
<>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>
</>
) : (
<>
<ArchiveIcon className="mr-2 h-4 w-4" />
<span>{t("archive") || "Archive"}</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDeleteThread(thread.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_chats_found") || "No chats found"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
<Users className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{showArchived
? t("no_archived_chats") || "No archived chats"
: t("no_shared_chats") || "No shared chats"}
</p>
{!showArchived && (
<p className="text-xs text-muted-foreground/70 mt-1">
Share a chat to collaborate with your team
</p>
)}
</div>
)}
</div>
</SidebarSlideOutPanel>
);
}

View file

@ -19,7 +19,6 @@ import {
Search,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -53,17 +52,13 @@ import {
isNewMentionMetadata,
isPageLimitExceededMetadata,
} from "@/contracts/types/inbox.types";
import type { InboxItem } from "@/hooks/use-inbox";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import type { InboxItem } from "@/hooks/use-inbox";
import { useMediaQuery } from "@/hooks/use-media-query";
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
import { useSidebarContextSafe } from "../../hooks";
// Sidebar width constants
const SIDEBAR_COLLAPSED_WIDTH = 60;
const SIDEBAR_EXPANDED_WIDTH = 240;
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
/**
* Get initials from name or email for avatar fallback
@ -561,13 +556,6 @@ export function InboxSidebar({
};
};
// Get sidebar collapsed state from context (provided by LayoutShell)
const sidebarContext = useSidebarContextSafe();
const isCollapsed = sidebarContext?.isCollapsed ?? false;
// Calculate the left position for the inbox panel (relative to sidebar)
const sidebarWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH;
if (!mounted) return null;
// Shared content component for both docked and floating modes
@ -1126,49 +1114,8 @@ export function InboxSidebar({
// FLOATING MODE: Render with animation and click-away layer
return (
<AnimatePresence>
{open && (
<>
{/* Click-away layer - only covers the content area, not the sidebar */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
style={{
left: isMobile ? 0 : sidebarWidth,
}}
className="absolute inset-y-0 right-0"
onClick={() => onOpenChange(false)}
aria-hidden="true"
/>
{/* Clip container - positioned at sidebar edge with overflow hidden */}
<div
style={{
left: isMobile ? 0 : sidebarWidth,
width: isMobile ? "100%" : 360,
}}
className={cn("absolute z-10 overflow-hidden pointer-events-none", "inset-y-0")}
>
<motion.div
initial={{ x: "-100%" }}
animate={{ x: 0 }}
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",
"sm:border-r sm:shadow-xl"
)}
role="dialog"
aria-modal="true"
aria-label={t("inbox") || "Inbox"}
>
{inboxContent}
</motion.div>
</div>
</>
)}
</AnimatePresence>
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
{inboxContent}
</SidebarSlideOutPanel>
);
}

View file

@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { ChatListItem } from "./ChatListItem";
import { NavSection } from "./NavSection";
@ -51,6 +52,9 @@ interface SidebarProps {
className?: string;
isLoadingChats?: boolean;
disableTooltips?: boolean;
sidebarWidth?: number;
onResizeMouseDown?: (e: React.MouseEvent) => void;
isResizing?: boolean;
}
export function Sidebar({
@ -80,17 +84,29 @@ export function Sidebar({
className,
isLoadingChats = false,
disableTooltips = false,
sidebarWidth = SIDEBAR_MIN_WIDTH,
onResizeMouseDown,
isResizing = false,
}: SidebarProps) {
const t = useTranslations("sidebar");
return (
<div
className={cn(
"flex h-full flex-col bg-sidebar text-sidebar-foreground transition-all duration-200 overflow-hidden",
isCollapsed ? "w-[60px]" : "w-[240px]",
"relative flex h-full flex-col bg-sidebar text-sidebar-foreground overflow-hidden",
isCollapsed ? "w-[60px] transition-all duration-200" : "",
!isCollapsed && !isResizing ? "transition-all duration-200" : "",
className
)}
style={!isCollapsed ? { width: sidebarWidth } : undefined}
>
{/* Resize handle on right border */}
{!isCollapsed && onResizeMouseDown && (
<div
onMouseDown={onResizeMouseDown}
className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-border active:bg-border z-10"
/>
)}
{/* Header - search space name or collapse button when collapsed */}
{isCollapsed ? (
<div className="flex h-14 shrink-0 items-center justify-center border-b">

View file

@ -0,0 +1,82 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
import { useSidebarContextSafe } from "../../hooks";
const SIDEBAR_COLLAPSED_WIDTH = 60;
interface SidebarSlideOutPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
ariaLabel: string;
width?: number;
children: React.ReactNode;
}
/**
* Reusable slide-out panel that appears from the right edge of the sidebar.
* Used by InboxSidebar (floating mode), AllSharedChatsSidebar, and AllPrivateChatsSidebar.
*
* Must be rendered inside a positioned container (the LayoutShell's relative flex container)
* and within the SidebarProvider context.
*/
export function SidebarSlideOutPanel({
open,
onOpenChange,
ariaLabel,
width = 360,
children,
}: SidebarSlideOutPanelProps) {
const isMobile = !useMediaQuery("(min-width: 640px)");
const sidebarContext = useSidebarContextSafe();
const isCollapsed = sidebarContext?.isCollapsed ?? false;
const sidebarWidth = isCollapsed
? SIDEBAR_COLLAPSED_WIDTH
: (sidebarContext?.sidebarWidth ?? 240);
return (
<AnimatePresence>
{open && (
<>
{/* Click-away layer - covers the full container including the sidebar */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 z-[5]"
onClick={() => onOpenChange(false)}
aria-hidden="true"
/>
{/* Clip container - positioned at sidebar edge with overflow hidden */}
<div
style={{
left: isMobile ? 0 : sidebarWidth,
width: isMobile ? "100%" : width,
}}
className={cn("absolute z-10 overflow-hidden pointer-events-none", "inset-y-0")}
>
<motion.div
initial={{ x: "-100%" }}
animate={{ x: 0 }}
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",
"sm:border-r sm:shadow-xl"
)}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
>
{children}
</motion.div>
</div>
</>
)}
</AnimatePresence>
);
}

View file

@ -1,16 +1,6 @@
"use client";
import {
Check,
ChevronUp,
Languages,
Laptop,
Loader2,
LogOut,
Moon,
Settings,
Sun,
} from "lucide-react";
import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
@ -25,6 +15,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useLocaleContext } from "@/contexts/LocaleContext";
import { cn } from "@/lib/utils";
@ -266,7 +257,7 @@ export function SidebarUserProfile({
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
{isLoggingOut ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-2" />
) : (
<LogOut className="mr-2 h-4 w-4" />
)}
@ -388,7 +379,7 @@ export function SidebarUserProfile({
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
{isLoggingOut ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-2" />
) : (
<LogOut className="mr-2 h-4 w-4" />
)}

View file

@ -1,10 +1,11 @@
"use client";
import { Copy, Loader2 } from "lucide-react";
import { Copy } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { publicChatApiService } from "@/lib/apis/public-chat-api.service";
import { getBearerToken } from "@/lib/auth-utils";
@ -61,9 +62,14 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
};
return (
<div className="mx-auto flex max-w-(--thread-max-width) items-center justify-center px-4 py-4">
<Button size="lg" onClick={handleCopyAndContinue} disabled={isCloning} className="gap-2">
{isCloning ? <Loader2 className="size-4 animate-spin" /> : <Copy className="size-4" />}
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2">
<Button
size="lg"
onClick={handleCopyAndContinue}
disabled={isCloning}
className="gap-2 rounded-full px-6 shadow-lg transition-all duration-200 hover:scale-[1.02] hover:shadow-xl hover:brightness-110 hover:bg-primary"
>
{isCloning ? <Spinner size="sm" /> : <Copy className="size-4" />}
Copy and continue this chat
</Button>
</div>

View file

@ -1,12 +1,12 @@
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { Loader2 } from "lucide-react";
import { Navbar } from "@/components/homepage/navbar";
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { Spinner } from "@/components/ui/spinner";
import { usePublicChat } from "@/hooks/use-public-chat";
import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime";
import { PublicChatFooter } from "./public-chat-footer";
@ -26,7 +26,7 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
<Navbar />
<div className="flex h-screen items-center justify-center">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
<Spinner size="lg" className="text-muted-foreground" />
</div>
</main>
);

View file

@ -71,8 +71,8 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
IMAGE_GEN_PROVIDERS,
getImageGenModelsByProvider,
IMAGE_GEN_PROVIDERS,
} from "@/contracts/enums/image-gen-providers";
import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";

View file

@ -1,7 +1,7 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { CheckIcon, MinusIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
@ -11,16 +11,17 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary data-[state=indeterminate]:bg-transparent data-[state=indeterminate]:text-foreground data-[state=indeterminate]:border-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
className="group flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
<CheckIcon className="size-3.5 hidden group-data-[state=checked]:block" />
<MinusIcon className="size-3.5 hidden group-data-[state=indeterminate]:block" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);