mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-27 19:25:15 +02:00
Merge remote-tracking branch 'upstream/dev' into fix/auth
This commit is contained in:
commit
2dec643cb4
80 changed files with 2968 additions and 2379 deletions
|
|
@ -16,7 +16,6 @@ import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/p
|
|||
|
||||
export function LocalLoginForm() {
|
||||
const t = useTranslations("auth");
|
||||
const tCommon = useTranslations("common");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
|
@ -58,12 +57,6 @@ export function LocalLoginForm() {
|
|||
sessionStorage.setItem("login_success_tracked", "true");
|
||||
}
|
||||
|
||||
// Success toast
|
||||
toast.success(t("login_success"), {
|
||||
description: "Redirecting to dashboard",
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Small delay to show success message
|
||||
setTimeout(() => {
|
||||
router.push(`/auth/callback?token=${data.access_token}`);
|
||||
|
|
@ -103,7 +96,7 @@ export function LocalLoginForm() {
|
|||
<form onSubmit={handleSubmit} className="space-y-3 md:space-y-4">
|
||||
{/* Error Display */}
|
||||
<AnimatePresence>
|
||||
{error && error.title && (
|
||||
{error?.title && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
export function getDocumentTypeIcon(type: string, className?: string): React.ReactNode {
|
||||
return getConnectorIcon(type, className);
|
||||
}
|
||||
|
||||
export function getDocumentTypeLabel(type: string): string {
|
||||
return type
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
const labelMap: Record<string, string> = {
|
||||
EXTENSION: "Extension",
|
||||
CRAWLED_URL: "Web Page",
|
||||
FILE: "File",
|
||||
SLACK_CONNECTOR: "Slack",
|
||||
TEAMS_CONNECTOR: "Microsoft Teams",
|
||||
NOTION_CONNECTOR: "Notion",
|
||||
YOUTUBE_VIDEO: "YouTube Video",
|
||||
GITHUB_CONNECTOR: "GitHub",
|
||||
LINEAR_CONNECTOR: "Linear",
|
||||
DISCORD_CONNECTOR: "Discord",
|
||||
JIRA_CONNECTOR: "Jira",
|
||||
CONFLUENCE_CONNECTOR: "Confluence",
|
||||
CLICKUP_CONNECTOR: "ClickUp",
|
||||
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||
GOOGLE_GMAIL_CONNECTOR: "Gmail",
|
||||
GOOGLE_DRIVE_FILE: "Google Drive",
|
||||
AIRTABLE_CONNECTOR: "Airtable",
|
||||
LUMA_CONNECTOR: "Luma",
|
||||
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
|
||||
BOOKSTACK_CONNECTOR: "BookStack",
|
||||
CIRCLEBACK: "Circleback",
|
||||
OBSIDIAN_CONNECTOR: "Obsidian",
|
||||
SURFSENSE_DOCS: "SurfSense Docs",
|
||||
NOTE: "Note",
|
||||
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Composio Google Drive",
|
||||
COMPOSIO_GMAIL_CONNECTOR: "Composio Gmail",
|
||||
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Composio Google Calendar",
|
||||
};
|
||||
return (
|
||||
labelMap[type] ||
|
||||
type
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
|
||||
.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentTypeChip({ type, className }: { type: string; className?: string }) {
|
||||
|
|
|
|||
|
|
@ -11,14 +11,13 @@ import {
|
|||
Clock,
|
||||
FileText,
|
||||
FileX,
|
||||
Loader2,
|
||||
Network,
|
||||
Plus,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
|
|
@ -354,11 +353,11 @@ export function DocumentsTableShell({
|
|||
<Skeleton className="h-4 w-4 rounded" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[35%] max-w-0 border-r border-border/40">
|
||||
<TableHead className="w-[40%] max-w-0 border-r border-border/40">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</TableHead>
|
||||
{columnVisibility.document_type && (
|
||||
<TableHead className="w-[20%] min-w-[120px] max-w-[200px] border-r border-border/40">
|
||||
<TableHead className="w-[15%] min-w-[100px] max-w-[170px] border-r border-border/40">
|
||||
<Skeleton className="h-3 w-14" />
|
||||
</TableHead>
|
||||
)}
|
||||
|
|
@ -396,11 +395,11 @@ export function DocumentsTableShell({
|
|||
<Skeleton className="h-4 w-4 rounded" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-[35%] py-2.5 max-w-0 border-r border-border/40">
|
||||
<TableCell className="w-[40%] py-2.5 max-w-0 border-r border-border/40">
|
||||
<Skeleton className="h-4" style={{ width: `${widthPercent}%` }} />
|
||||
</TableCell>
|
||||
{columnVisibility.document_type && (
|
||||
<TableCell className="w-[20%] min-w-[120px] max-w-[200px] py-2.5 border-r border-border/40 overflow-hidden">
|
||||
<TableCell className="w-[15%] min-w-[100px] max-w-[170px] py-2.5 border-r border-border/40 overflow-hidden">
|
||||
<Skeleton className="h-5 w-24 rounded" />
|
||||
</TableCell>
|
||||
)}
|
||||
|
|
@ -499,7 +498,7 @@ export function DocumentsTableShell({
|
|||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[35%] border-r border-border/40">
|
||||
<TableHead className="w-[40%] border-r border-border/40">
|
||||
<SortableHeader
|
||||
sortKey="title"
|
||||
currentSortKey={sortKey}
|
||||
|
|
@ -511,7 +510,7 @@ export function DocumentsTableShell({
|
|||
</SortableHeader>
|
||||
</TableHead>
|
||||
{columnVisibility.document_type && (
|
||||
<TableHead className="w-[20%] min-w-[120px] max-w-[200px] border-r border-border/40">
|
||||
<TableHead className="w-[15%] min-w-[100px] max-w-[170px] border-r border-border/40">
|
||||
<SortableHeader
|
||||
sortKey="document_type"
|
||||
currentSortKey={sortKey}
|
||||
|
|
@ -594,7 +593,7 @@ export function DocumentsTableShell({
|
|||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-[35%] py-2.5 max-w-0 border-r border-border/40">
|
||||
<TableCell className="w-[40%] py-2.5 max-w-0 border-r border-border/40">
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full text-left text-sm text-foreground hover:text-foreground transition-colors cursor-pointer bg-transparent border-0 p-0 truncate"
|
||||
|
|
@ -624,7 +623,7 @@ export function DocumentsTableShell({
|
|||
</button>
|
||||
</TableCell>
|
||||
{columnVisibility.document_type && (
|
||||
<TableCell className="w-[20%] min-w-[120px] max-w-[200px] py-2.5 border-r border-border/40 overflow-hidden">
|
||||
<TableCell className="w-[15%] min-w-[100px] max-w-[170px] py-2.5 border-r border-border/40 overflow-hidden">
|
||||
<DocumentTypeChip type={doc.document_type} />
|
||||
</TableCell>
|
||||
)}
|
||||
|
|
@ -773,7 +772,7 @@ export function DocumentsTableShell({
|
|||
<div className="mt-4">
|
||||
{viewingLoading ? (
|
||||
<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>
|
||||
) : (
|
||||
<MarkdownViewer content={viewingContent} />
|
||||
|
|
|
|||
|
|
@ -50,13 +50,16 @@ export function RowActions({
|
|||
const isBeingProcessed =
|
||||
document.status?.state === "pending" || document.status?.state === "processing";
|
||||
|
||||
// FILE documents that failed processing cannot be edited
|
||||
const isFileFailed = document.document_type === "FILE" && document.status?.state === "failed";
|
||||
|
||||
// SURFSENSE_DOCS are system-managed and should not show delete at all
|
||||
const shouldShowDelete = !NON_DELETABLE_DOCUMENT_TYPES.includes(
|
||||
document.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
|
||||
);
|
||||
|
||||
// Edit and Delete are disabled while processing
|
||||
const isEditDisabled = isBeingProcessed;
|
||||
// Edit is disabled while processing OR for failed FILE documents
|
||||
const isEditDisabled = isBeingProcessed || isFileFailed;
|
||||
const isDeleteDisabled = isBeingProcessed;
|
||||
|
||||
const handleDelete = async () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ExternalLink, Gift, Loader2, Mail, Star } from "lucide-react";
|
||||
import { Check, ExternalLink, Gift, Mail, Star } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
|
||||
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
||||
import {
|
||||
|
|
@ -144,7 +145,7 @@ export default function MorePagesPage() {
|
|||
className="gap-1"
|
||||
>
|
||||
{completeMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<>
|
||||
Go
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ import { useMessagesElectric } from "@/hooks/use-messages-electric";
|
|||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
|
||||
import { convertToThreadMessage } from "@/lib/chat/message-utils";
|
||||
import {
|
||||
isPodcastGenerating,
|
||||
|
|
@ -216,9 +215,6 @@ export default function NewChatPage() {
|
|||
|
||||
useMessagesElectric(threadId, handleElectricMessagesUpdate);
|
||||
|
||||
// Create the attachment adapter for file processing
|
||||
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
|
||||
|
||||
// Extract search_space_id from URL params
|
||||
const searchSpaceId = useMemo(() => {
|
||||
const id = params.search_space_id;
|
||||
|
|
@ -409,16 +405,7 @@ export default function NewChatPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// Extract attachments from message
|
||||
// AppendMessage.attachments contains the processed attachment objects (from adapter.send())
|
||||
const messageAttachments: Array<Record<string, unknown>> = [];
|
||||
if (message.attachments && message.attachments.length > 0) {
|
||||
for (const att of message.attachments) {
|
||||
messageAttachments.push(att as unknown as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
if (!userQuery.trim() && messageAttachments.length === 0) return;
|
||||
if (!userQuery.trim()) return;
|
||||
|
||||
// Check if podcast is already generating
|
||||
if (isPodcastGenerating() && looksLikePodcastRequest(userQuery)) {
|
||||
|
|
@ -485,14 +472,13 @@ export default function NewChatPage() {
|
|||
role: "user",
|
||||
content: message.content,
|
||||
createdAt: new Date(),
|
||||
attachments: message.attachments || [],
|
||||
metadata: authorMetadata,
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
// Track message sent
|
||||
trackChatMessageSent(searchSpaceId, currentThreadId, {
|
||||
hasAttachments: messageAttachments.length > 0,
|
||||
hasAttachments: false,
|
||||
hasMentionedDocuments:
|
||||
mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
|
||||
mentionedDocumentIds.document_ids.length > 0,
|
||||
|
|
@ -512,7 +498,7 @@ export default function NewChatPage() {
|
|||
}));
|
||||
}
|
||||
|
||||
// Persist user message with mentioned documents and attachments (don't await, fire and forget)
|
||||
// Persist user message with mentioned documents (don't await, fire and forget)
|
||||
const persistContent: unknown[] = [...message.content];
|
||||
|
||||
// Add mentioned documents for persistence
|
||||
|
|
@ -527,23 +513,6 @@ export default function NewChatPage() {
|
|||
});
|
||||
}
|
||||
|
||||
// Add attachments for persistence (so they survive page reload)
|
||||
if (message.attachments && message.attachments.length > 0) {
|
||||
persistContent.push({
|
||||
type: "attachments",
|
||||
items: message.attachments.map((att) => ({
|
||||
id: att.id,
|
||||
name: att.name,
|
||||
type: att.type,
|
||||
contentType: (att as { contentType?: string }).contentType,
|
||||
// Include imageDataUrl for images so they can be displayed after reload
|
||||
imageDataUrl: (att as { imageDataUrl?: string }).imageDataUrl,
|
||||
// Include extractedContent for context (already extracted, no re-processing needed)
|
||||
extractedContent: (att as { extractedContent?: string }).extractedContent,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
appendMessage(currentThreadId, {
|
||||
role: "user",
|
||||
content: persistContent,
|
||||
|
|
@ -688,9 +657,6 @@ export default function NewChatPage() {
|
|||
})
|
||||
.filter((m) => m.content.length > 0);
|
||||
|
||||
// Extract attachment content to send with the request
|
||||
const attachments = extractAttachmentContent(messageAttachments);
|
||||
|
||||
// Get mentioned document IDs for context (separate fields for backend)
|
||||
const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
|
||||
const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0;
|
||||
|
|
@ -715,7 +681,6 @@ export default function NewChatPage() {
|
|||
user_query: userQuery.trim(),
|
||||
search_space_id: searchSpaceId,
|
||||
messages: messageHistory,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined,
|
||||
mentioned_surfsense_doc_ids: hasSurfsenseDocIds
|
||||
? mentionedDocumentIds.surfsense_doc_ids
|
||||
|
|
@ -1010,7 +975,6 @@ export default function NewChatPage() {
|
|||
// Extract the original user query BEFORE removing messages (for reload mode)
|
||||
let userQueryToDisplay = newUserQuery;
|
||||
let originalUserMessageContent: ThreadMessageLike["content"] | null = null;
|
||||
let originalUserMessageAttachments: ThreadMessageLike["attachments"] | undefined;
|
||||
let originalUserMessageMetadata: ThreadMessageLike["metadata"] | undefined;
|
||||
|
||||
if (!newUserQuery) {
|
||||
|
|
@ -1018,7 +982,6 @@ export default function NewChatPage() {
|
|||
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
|
||||
if (lastUserMessage) {
|
||||
originalUserMessageContent = lastUserMessage.content;
|
||||
originalUserMessageAttachments = lastUserMessage.attachments;
|
||||
originalUserMessageMetadata = lastUserMessage.metadata;
|
||||
// Extract text for the API request
|
||||
for (const part of lastUserMessage.content) {
|
||||
|
|
@ -1144,7 +1107,6 @@ export default function NewChatPage() {
|
|||
? [{ type: "text", text: newUserQuery }]
|
||||
: originalUserMessageContent || [{ type: "text", text: userQueryToDisplay || "" }],
|
||||
createdAt: new Date(),
|
||||
attachments: newUserQuery ? undefined : originalUserMessageAttachments,
|
||||
metadata: newUserQuery ? undefined : originalUserMessageMetadata,
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
|
@ -1391,7 +1353,7 @@ export default function NewChatPage() {
|
|||
await handleRegenerate(null);
|
||||
}, [handleRegenerate]);
|
||||
|
||||
// Create external store runtime with attachment support
|
||||
// Create external store runtime
|
||||
const runtime = useExternalStoreRuntime({
|
||||
messages,
|
||||
isRunning,
|
||||
|
|
@ -1400,9 +1362,6 @@ export default function NewChatPage() {
|
|||
onReload,
|
||||
convertMessage,
|
||||
onCancel: cancelRun,
|
||||
adapters: {
|
||||
attachments: attachmentAdapter,
|
||||
},
|
||||
});
|
||||
|
||||
// Show loading state only when loading an existing thread
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import { userApiService } from "@/lib/apis/user-api.service";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { getBearerToken, isPublicRoute } from "@/lib/auth-utils";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
export const currentUserAtom = atomWithQuery(() => {
|
||||
const pathname = typeof window !== "undefined" ? window.location.pathname : null;
|
||||
return {
|
||||
queryKey: cacheKeys.user.current(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
// Only fetch user data when a bearer token is present
|
||||
enabled: !!getBearerToken(),
|
||||
queryFn: async () => {
|
||||
return userApiService.getMe();
|
||||
},
|
||||
enabled: !!getBearerToken() && pathname !== null && !isPublicRoute(pathname),
|
||||
queryFn: async () => userApiService.getMe(),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
72
surfsense_web/changelog/content/2026-02-09.mdx
Normal file
72
surfsense_web/changelog/content/2026-02-09.mdx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
title: "SurfSense v0.0.13 - Public Sharing, Image Generation & Redesigned Documents"
|
||||
description: "SurfSense v0.0.13 introduces public chat sharing with permissions, image generation support, an auto load-balanced model mode, a redesigned Documents page, and numerous bug fixes across connectors and UI."
|
||||
date: "2026-02-09"
|
||||
tags: ["Public Sharing", "Image Generation", "Documents", "UI", "Bug Fixes"]
|
||||
version: "0.0.13"
|
||||
---
|
||||
|
||||
## What's New in v0.0.13
|
||||
|
||||
This update brings **public sharing, image generation**, a redesigned Documents page, and numerous bug fixes.
|
||||
|
||||
### Features & Improvements
|
||||
|
||||
#### Image Generation
|
||||
|
||||
- **Image Generation**: Generate images directly in chat with custom model and provider configurations.
|
||||
|
||||
#### Public Sharing
|
||||
|
||||
- **Public Chat Links**: Share snapshots of chats via public links.
|
||||
- **Sharing Permissions**: Search Space owners control who can create and manage public links.
|
||||
- **Link Management Page**: View and revoke all public chat links from Search Space Settings.
|
||||
|
||||
#### Auto (Load Balanced) Mode
|
||||
|
||||
- **Auto Model Selection**: The default cloud model now automatically picks the lowest-load model for faster responses.
|
||||
|
||||
#### Redesigned Documents Page
|
||||
|
||||
- **Unified Page**: Merged Logs and Documents into one page with real-time statuses.
|
||||
- **Inline Connector Management**: Configure and monitor connectors alongside your documents.
|
||||
|
||||
#### UI & UX Polish
|
||||
|
||||
- **Inbox Refresh**: Cleaner inbox layout for easier scanning.
|
||||
- **Streamlined Login**: Consolidated loading screens into a single unified flow.
|
||||
- **Chat UI Tweaks**: Small refinements for a cleaner, more consistent chat experience.
|
||||
- **Prompt Suggestions**: Empty chat windows now show contextual suggestions.
|
||||
|
||||
#### Documentation
|
||||
|
||||
- **New Connector Docs**: Added docs for Luma, Circleback, Elasticsearch, Bookstack, and Obsidian connectors.
|
||||
|
||||
<Accordion type="multiple" className="w-full not-prose">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Bug Fixes</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||
<ul className="list-disc space-y-2 pl-4">
|
||||
<li>Fixed cloud scaling issues where document queue congestion occurred under high load</li>
|
||||
<li>Documents now correctly attribute to the uploading user and de-index on disconnect or deletion</li>
|
||||
<li>Fixed common backend errors in indexing and large file handling</li>
|
||||
<li>Fixed Notion indexing failures caused by transcription blocks</li>
|
||||
<li>Chat refresh button now correctly regenerates AI responses</li>
|
||||
<li>Restored the previously disabled Role Editor</li>
|
||||
<li>Fixed Mentions tab appearing empty when document notifications pushed mentions out of the pagination window</li>
|
||||
<li>Bundled git in the Docker image to fix GitHub connector failures with gitingest</li>
|
||||
<li>Fixed Google Calendar default date range errors and aligned backend defaults with the frontend</li>
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Technical Improvements</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||
<ul className="list-disc space-y-2 pl-4">
|
||||
<li>Rebuilt the GitHub connector on gitingest for more efficient, lower-cost repository fetching</li>
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
SurfSense is your AI-powered federated search solution, connecting all your knowledge sources in one place.
|
||||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ interface SidebarContextValue {
|
|||
isCollapsed: boolean;
|
||||
setIsCollapsed: (collapsed: boolean) => void;
|
||||
toggleCollapsed: () => void;
|
||||
sidebarWidth: number;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextValue | null>(null);
|
||||
|
|
|
|||
101
surfsense_web/components/layout/hooks/useSidebarResize.ts
Normal file
101
surfsense_web/components/layout/hooks/useSidebarResize.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -73,8 +73,8 @@ import { Separator } from "@/components/ui/separator";
|
|||
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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1477,6 +1477,78 @@ export const LLM_MODELS: LLMModel[] = [
|
|||
provider: "DATABRICKS",
|
||||
contextWindow: "128K",
|
||||
},
|
||||
|
||||
// GitHub Models
|
||||
{
|
||||
value: "openai/gpt-5",
|
||||
label: "GitHub GPT-5",
|
||||
provider: "GITHUB_MODELS",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-4.1",
|
||||
label: "GitHub GPT-4.1",
|
||||
provider: "GITHUB_MODELS",
|
||||
contextWindow: "1048K",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-4o",
|
||||
label: "GitHub GPT-4o",
|
||||
provider: "GITHUB_MODELS",
|
||||
contextWindow: "128K",
|
||||
},
|
||||
{
|
||||
value: "deepseek/DeepSeek-V3-0324",
|
||||
label: "GitHub DeepSeek V3",
|
||||
provider: "GITHUB_MODELS",
|
||||
contextWindow: "64K",
|
||||
},
|
||||
{
|
||||
value: "xai/grok-3",
|
||||
label: "GitHub Grok 3",
|
||||
provider: "GITHUB_MODELS",
|
||||
contextWindow: "131K",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5-mini",
|
||||
label: "GitHub GPT-5 Mini",
|
||||
provider: "GITHUB_MODELS",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-4.1-mini",
|
||||
label: "GitHub GPT-4.1 Mini",
|
||||
provider: "GITHUB_MODELS",
|
||||
contextWindow: "1048K",
|
||||
},
|
||||
{
|
||||
value: "meta/Llama-4-Scout-17B-16E-Instruct",
|
||||
label: "GitHub Llama 4 Scout",
|
||||
provider: "GITHUB_MODELS",
|
||||
contextWindow: "512K",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-4.1-nano",
|
||||
label: "GitHub GPT-4.1 Nano",
|
||||
provider: "GITHUB_MODELS",
|
||||
contextWindow: "1048K",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-4o-mini",
|
||||
label: "GitHub GPT-4o Mini",
|
||||
provider: "GITHUB_MODELS",
|
||||
contextWindow: "128K",
|
||||
},
|
||||
{
|
||||
value: "openai/o4-mini",
|
||||
label: "GitHub O4 Mini",
|
||||
provider: "GITHUB_MODELS",
|
||||
contextWindow: "200K",
|
||||
},
|
||||
{
|
||||
value: "deepseek/DeepSeek-R1",
|
||||
label: "GitHub DeepSeek R1",
|
||||
provider: "GITHUB_MODELS",
|
||||
contextWindow: "64K",
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to get models by provider
|
||||
|
|
|
|||
|
|
@ -174,6 +174,13 @@ export const LLM_PROVIDERS: LLMProvider[] = [
|
|||
example: "databricks/databricks-meta-llama-3-3-70b-instruct",
|
||||
description: "Databricks Model Serving",
|
||||
},
|
||||
{
|
||||
value: "GITHUB_MODELS",
|
||||
label: "GitHub Models",
|
||||
example: "openai/gpt-5, meta/llama-3.1-405b-instruct",
|
||||
description: "AI models from GitHub Marketplace",
|
||||
apiBase: "https://models.github.ai/inference",
|
||||
},
|
||||
{
|
||||
value: "CUSTOM",
|
||||
label: "Custom Provider",
|
||||
|
|
|
|||
|
|
@ -138,6 +138,37 @@ export const uploadDocumentRequest = z.object({
|
|||
|
||||
export const uploadDocumentResponse = z.object({
|
||||
message: z.literal("Files uploaded for processing"),
|
||||
document_ids: z.array(z.number()),
|
||||
duplicate_document_ids: z.array(z.number()).optional(),
|
||||
total_files: z.number().optional(),
|
||||
pending_files: z.number().optional(),
|
||||
skipped_duplicates: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Batch document status
|
||||
*/
|
||||
export const getDocumentsStatusRequest = z.object({
|
||||
queryParams: z.object({
|
||||
search_space_id: z.number(),
|
||||
document_ids: z.array(z.number()).min(1),
|
||||
}),
|
||||
});
|
||||
|
||||
export const documentStatus = z.object({
|
||||
state: z.enum(["ready", "pending", "processing", "failed"]),
|
||||
reason: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const documentStatusItem = z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
document_type: documentTypeEnum,
|
||||
status: documentStatus,
|
||||
});
|
||||
|
||||
export const getDocumentsStatusResponse = z.object({
|
||||
items: z.array(documentStatusItem),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -261,6 +292,10 @@ export type CreateDocumentRequest = z.infer<typeof createDocumentRequest>;
|
|||
export type CreateDocumentResponse = z.infer<typeof createDocumentResponse>;
|
||||
export type UploadDocumentRequest = z.infer<typeof uploadDocumentRequest>;
|
||||
export type UploadDocumentResponse = z.infer<typeof uploadDocumentResponse>;
|
||||
export type GetDocumentsStatusRequest = z.infer<typeof getDocumentsStatusRequest>;
|
||||
export type GetDocumentsStatusResponse = z.infer<typeof getDocumentsStatusResponse>;
|
||||
export type DocumentStatus = z.infer<typeof documentStatus>;
|
||||
export type DocumentStatusItem = z.infer<typeof documentStatusItem>;
|
||||
export type SearchDocumentsRequest = z.infer<typeof searchDocumentsRequest>;
|
||||
export type SearchDocumentsResponse = z.infer<typeof searchDocumentsResponse>;
|
||||
export type SearchDocumentTitlesRequest = z.infer<typeof searchDocumentTitlesRequest>;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { z } from "zod";
|
|||
/**
|
||||
* Incentive task type enum - matches backend IncentiveTaskType
|
||||
*/
|
||||
export const incentiveTaskTypeEnum = z.enum(["GITHUB_STAR"]);
|
||||
export const incentiveTaskTypeEnum = z.enum(["GITHUB_STAR", "REDDIT_FOLLOW", "DISCORD_JOIN"]);
|
||||
|
||||
/**
|
||||
* Single incentive task info schema
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export const liteLLMProviderEnum = z.enum([
|
|||
"DATABRICKS",
|
||||
"COMETAPI",
|
||||
"HUGGINGFACE",
|
||||
"GITHUB_MODELS",
|
||||
"CUSTOM",
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type GetDocumentByChunkRequest,
|
||||
type GetDocumentRequest,
|
||||
type GetDocumentsRequest,
|
||||
type GetDocumentsStatusRequest,
|
||||
type GetDocumentTypeCountsRequest,
|
||||
type GetSurfsenseDocsRequest,
|
||||
getDocumentByChunkRequest,
|
||||
|
|
@ -16,6 +17,8 @@ import {
|
|||
getDocumentResponse,
|
||||
getDocumentsRequest,
|
||||
getDocumentsResponse,
|
||||
getDocumentsStatusRequest,
|
||||
getDocumentsStatusResponse,
|
||||
getDocumentTypeCountsRequest,
|
||||
getDocumentTypeCountsResponse,
|
||||
getSurfsenseDocsByChunkResponse,
|
||||
|
|
@ -130,6 +133,30 @@ class DocumentsApiService {
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Batch document status for async processing tracking
|
||||
*/
|
||||
getDocumentsStatus = async (request: GetDocumentsStatusRequest) => {
|
||||
const parsedRequest = getDocumentsStatusRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { search_space_id, document_ids } = parsedRequest.data.queryParams;
|
||||
const params = new URLSearchParams({
|
||||
search_space_id: String(search_space_id),
|
||||
document_ids: document_ids.join(","),
|
||||
});
|
||||
|
||||
return baseApiService.get(
|
||||
`/api/v1/documents/status?${params.toString()}`,
|
||||
getDocumentsStatusResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Search documents by title
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import {
|
|||
type CreateImageGenConfigRequest,
|
||||
createImageGenConfigRequest,
|
||||
createImageGenConfigResponse,
|
||||
deleteImageGenConfigResponse,
|
||||
getGlobalImageGenConfigsResponse,
|
||||
getImageGenConfigsResponse,
|
||||
type UpdateImageGenConfigRequest,
|
||||
updateImageGenConfigRequest,
|
||||
updateImageGenConfigResponse,
|
||||
deleteImageGenConfigResponse,
|
||||
getImageGenConfigsResponse,
|
||||
getGlobalImageGenConfigsResponse,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
|
|
|||
|
|
@ -10,28 +10,53 @@ const REFRESH_TOKEN_KEY = "surfsense_refresh_token";
|
|||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
/** Path prefixes for routes that do not require auth (no current-user fetch, no redirect on 401) */
|
||||
const PUBLIC_ROUTE_PREFIXES = [
|
||||
"/login",
|
||||
"/register",
|
||||
"/auth",
|
||||
"/docs",
|
||||
"/public",
|
||||
"/invite",
|
||||
"/contact",
|
||||
"/pricing",
|
||||
"/privacy",
|
||||
"/terms",
|
||||
"/changelog",
|
||||
];
|
||||
|
||||
/**
|
||||
* Saves the current path and redirects to login page
|
||||
* Call this when a 401 response is received
|
||||
* Returns true if the pathname is a public route where we should not run auth checks
|
||||
* or redirect to login on 401.
|
||||
*/
|
||||
export function isPublicRoute(pathname: string): boolean {
|
||||
if (pathname === "/" || pathname === "") return true;
|
||||
return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears tokens and optionally redirects to login.
|
||||
* Call this when a 401 response is received.
|
||||
* Only redirects when the current route is protected; on public routes we just clear tokens.
|
||||
*/
|
||||
export function handleUnauthorized(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Save the current path (including search params and hash) for redirect after login
|
||||
const currentPath = window.location.pathname + window.location.search + window.location.hash;
|
||||
const pathname = window.location.pathname;
|
||||
|
||||
// Don't save auth-related paths
|
||||
const excludedPaths = ["/auth", "/auth/callback", "/"];
|
||||
if (!excludedPaths.includes(window.location.pathname)) {
|
||||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||
}
|
||||
|
||||
// Clear both tokens
|
||||
// Always clear tokens
|
||||
localStorage.removeItem(BEARER_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
|
||||
// Redirect to home page (which has login options)
|
||||
window.location.href = "/login";
|
||||
// Only redirect on protected routes; stay on public pages (e.g. /docs)
|
||||
if (!isPublicRoute(pathname)) {
|
||||
const currentPath = pathname + window.location.search + window.location.hash;
|
||||
const excludedPaths = ["/auth", "/auth/callback", "/"];
|
||||
if (!excludedPaths.includes(pathname)) {
|
||||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||
}
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -179,7 +204,6 @@ export function getAuthHeaders(additionalHeaders?: Record<string, string>): Reco
|
|||
/**
|
||||
* Attempts to refresh the access token using the stored refresh token.
|
||||
* Returns the new access token if successful, null otherwise.
|
||||
* Exported for use by API services.
|
||||
*/
|
||||
export async function refreshAccessToken(): Promise<string | null> {
|
||||
// If already refreshing, wait for that request to complete
|
||||
|
|
|
|||
|
|
@ -1,324 +0,0 @@
|
|||
/**
|
||||
* Attachment adapter for assistant-ui
|
||||
*
|
||||
* This adapter handles file uploads by:
|
||||
* 1. Uploading the file to the backend /attachments/process endpoint
|
||||
* 2. The backend extracts markdown content using the configured ETL service
|
||||
* 3. The extracted content is stored in the attachment and sent with messages
|
||||
*/
|
||||
|
||||
import type { AttachmentAdapter, CompleteAttachment, PendingAttachment } from "@assistant-ui/react";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
|
||||
/**
|
||||
* Supported file types for the attachment adapter
|
||||
*
|
||||
* - Text/Markdown: .md, .markdown, .txt
|
||||
* - Audio (if STT configured): .mp3, .mp4, .mpeg, .mpga, .m4a, .wav, .webm
|
||||
* - Documents (depends on ETL service): .pdf, .docx, .doc, .pptx, .xlsx, .html
|
||||
* - Images: .jpg, .jpeg, .png, .gif, .webp
|
||||
*/
|
||||
const ACCEPTED_FILE_TYPES = [
|
||||
// Text/Markdown (always supported)
|
||||
".md",
|
||||
".markdown",
|
||||
".txt",
|
||||
// Audio files
|
||||
".mp3",
|
||||
".mp4",
|
||||
".mpeg",
|
||||
".mpga",
|
||||
".m4a",
|
||||
".wav",
|
||||
".webm",
|
||||
// Document files (depends on ETL service)
|
||||
".pdf",
|
||||
".docx",
|
||||
".doc",
|
||||
".pptx",
|
||||
".xlsx",
|
||||
".html",
|
||||
// Image files
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
].join(",");
|
||||
|
||||
/**
|
||||
* Response from the attachment processing endpoint
|
||||
*/
|
||||
interface ProcessAttachmentResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "document" | "image" | "file";
|
||||
content: string;
|
||||
contentLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended CompleteAttachment with our custom extractedContent field
|
||||
* We store the extracted text in a custom field so we can access it in onNew
|
||||
* For images, we also store the data URL so it can be displayed after persistence
|
||||
*/
|
||||
export interface ChatAttachment extends CompleteAttachment {
|
||||
extractedContent: string;
|
||||
imageDataUrl?: string; // Base64 data URL for images (persists across page reloads)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a file through the backend ETL service
|
||||
*/
|
||||
async function processAttachment(file: File): Promise<ProcessAttachmentResponse> {
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(`${backendUrl}/api/v1/attachments/process`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("[processAttachment] Error response:", errorText);
|
||||
let errorDetail = "Unknown error";
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
// FastAPI validation errors return detail as array
|
||||
if (Array.isArray(errorJson.detail)) {
|
||||
errorDetail = errorJson.detail
|
||||
.map((err: { msg?: string; loc?: string[] }) => {
|
||||
const field = err.loc?.join(".") || "unknown";
|
||||
return `${field}: ${err.msg || "validation error"}`;
|
||||
})
|
||||
.join("; ");
|
||||
} else if (typeof errorJson.detail === "string") {
|
||||
errorDetail = errorJson.detail;
|
||||
} else {
|
||||
errorDetail = JSON.stringify(errorJson);
|
||||
}
|
||||
} catch {
|
||||
errorDetail = errorText || `HTTP ${response.status}`;
|
||||
}
|
||||
throw new Error(errorDetail);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Store processed results for the send() method
|
||||
const processedAttachments = new Map<string, ProcessAttachmentResponse>();
|
||||
|
||||
// Store image data URLs for attachments (so they persist after File objects are lost)
|
||||
const imageDataUrls = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Convert a File to a data URL (base64) for images
|
||||
*/
|
||||
async function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the attachment adapter for assistant-ui
|
||||
*
|
||||
* This adapter:
|
||||
* 1. Accepts file upload
|
||||
* 2. Processes the file through the backend ETL service
|
||||
* 3. Returns the attachment with extracted markdown content
|
||||
*
|
||||
* The content is stored in the attachment and will be sent with the message.
|
||||
*/
|
||||
export function createAttachmentAdapter(): AttachmentAdapter {
|
||||
return {
|
||||
accept: ACCEPTED_FILE_TYPES,
|
||||
|
||||
/**
|
||||
* Async generator that yields pending states while processing
|
||||
* and returns a pending attachment when done.
|
||||
*
|
||||
* IMPORTANT: The generator should return status: { type: "running", progress: 100 }
|
||||
* NOT status: { type: "complete" }. The "complete" status is set by send().
|
||||
* Returning "complete" from the generator will prevent send() from being called!
|
||||
*
|
||||
* This pattern allows the UI to show a loading indicator
|
||||
* while the file is being processed by the backend.
|
||||
* The send() method is called to finalize the attachment.
|
||||
*/
|
||||
async *add(input: File | { file: File }): AsyncGenerator<PendingAttachment, void> {
|
||||
// Handle both direct File and { file: File } patterns
|
||||
const file = input instanceof File ? input : input.file;
|
||||
|
||||
if (!file) {
|
||||
console.error("[AttachmentAdapter] No file found in input:", input);
|
||||
throw new Error("No file provided");
|
||||
}
|
||||
|
||||
// Generate a unique ID for this attachment
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
// Determine attachment type from file
|
||||
const attachmentType = file.type.startsWith("image/") ? "image" : "document";
|
||||
|
||||
// Yield initial pending state with "running" status (0% progress)
|
||||
// This triggers the loading indicator in the UI
|
||||
yield {
|
||||
id,
|
||||
type: attachmentType,
|
||||
name: file.name,
|
||||
file,
|
||||
status: { type: "running", reason: "uploading", progress: 0 },
|
||||
} as PendingAttachment;
|
||||
|
||||
try {
|
||||
// For images, convert to data URL so we can display them after persistence
|
||||
if (attachmentType === "image") {
|
||||
const dataUrl = await fileToDataUrl(file);
|
||||
imageDataUrls.set(id, dataUrl);
|
||||
}
|
||||
|
||||
// Process the file through the backend ETL service
|
||||
const result = await processAttachment(file);
|
||||
|
||||
// Verify we have the required fields
|
||||
if (!result.content) {
|
||||
console.error("[AttachmentAdapter] WARNING: No content received from backend!");
|
||||
}
|
||||
|
||||
// Store the processed result for send()
|
||||
processedAttachments.set(id, result);
|
||||
|
||||
// Create the final pending attachment
|
||||
// IMPORTANT: Use "running" status with progress: 100 to indicate processing is done
|
||||
// but attachment is still pending. The "complete" status will be set by send().
|
||||
// Yield the final state to ensure it gets processed by the UI
|
||||
yield {
|
||||
id,
|
||||
type: result.type,
|
||||
name: result.name,
|
||||
file,
|
||||
status: { type: "running", reason: "uploading", progress: 100 },
|
||||
} as PendingAttachment;
|
||||
} catch (error) {
|
||||
console.error("[AttachmentAdapter] Failed to process attachment:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when user sends the message.
|
||||
* Converts the pending attachment to a complete attachment.
|
||||
*/
|
||||
async send(pendingAttachment: PendingAttachment): Promise<ChatAttachment> {
|
||||
const result = processedAttachments.get(pendingAttachment.id);
|
||||
const imageDataUrl = imageDataUrls.get(pendingAttachment.id);
|
||||
|
||||
if (result) {
|
||||
// Clean up stored result
|
||||
processedAttachments.delete(pendingAttachment.id);
|
||||
if (imageDataUrl) {
|
||||
imageDataUrls.delete(pendingAttachment.id);
|
||||
}
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
type: result.type,
|
||||
name: result.name,
|
||||
contentType: "text/markdown",
|
||||
status: { type: "complete" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: result.content,
|
||||
},
|
||||
],
|
||||
extractedContent: result.content,
|
||||
imageDataUrl, // Store data URL for images so they can be displayed after persistence
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback if no processed result found
|
||||
console.warn(
|
||||
"[AttachmentAdapter] send() - No processed result found for attachment:",
|
||||
pendingAttachment.id
|
||||
);
|
||||
return {
|
||||
id: pendingAttachment.id,
|
||||
type: pendingAttachment.type,
|
||||
name: pendingAttachment.name,
|
||||
contentType: "text/plain",
|
||||
status: { type: "complete" },
|
||||
content: [],
|
||||
extractedContent: "",
|
||||
imageDataUrl, // Still include data URL if available
|
||||
};
|
||||
},
|
||||
|
||||
async remove() {
|
||||
// No server-side cleanup needed since we don't persist attachments
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract attachment content for chat request
|
||||
*
|
||||
* This function extracts the content from attachments to be sent with the chat request.
|
||||
* Only attachments that have been fully processed (have content) will be included.
|
||||
*/
|
||||
export function extractAttachmentContent(
|
||||
attachments: Array<unknown>
|
||||
): Array<{ id: string; name: string; type: string; content: string }> {
|
||||
return attachments
|
||||
.filter((att): att is ChatAttachment => {
|
||||
if (!att || typeof att !== "object") return false;
|
||||
const a = att as Record<string, unknown>;
|
||||
// Check for our custom extractedContent field first
|
||||
if (typeof a.extractedContent === "string" && a.extractedContent.length > 0) {
|
||||
return true;
|
||||
}
|
||||
// Fallback: check if content array has text content
|
||||
if (Array.isArray(a.content)) {
|
||||
const textContent = (a.content as Array<{ type: string; text?: string }>).find(
|
||||
(c) => c.type === "text" && typeof c.text === "string" && c.text.length > 0
|
||||
);
|
||||
return Boolean(textContent);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map((att) => {
|
||||
// Get content from extractedContent or from content array
|
||||
let content = "";
|
||||
if (typeof att.extractedContent === "string") {
|
||||
content = att.extractedContent;
|
||||
} else if (Array.isArray(att.content)) {
|
||||
const textContent = (att.content as Array<{ type: string; text?: string }>).find(
|
||||
(c) => c.type === "text"
|
||||
);
|
||||
content = textContent?.text || "";
|
||||
}
|
||||
|
||||
return {
|
||||
id: att.id,
|
||||
name: att.name,
|
||||
type: att.type,
|
||||
content,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -1,46 +1,9 @@
|
|||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
import { z } from "zod";
|
||||
import type { MessageRecord } from "./thread-persistence";
|
||||
|
||||
/**
|
||||
* Zod schema for persisted attachment info
|
||||
*/
|
||||
const PersistedAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
contentType: z.string().optional(),
|
||||
imageDataUrl: z.string().optional(),
|
||||
extractedContent: z.string().optional(),
|
||||
});
|
||||
|
||||
const AttachmentsPartSchema = z.object({
|
||||
type: z.literal("attachments"),
|
||||
items: z.array(PersistedAttachmentSchema),
|
||||
});
|
||||
|
||||
type PersistedAttachment = z.infer<typeof PersistedAttachmentSchema>;
|
||||
|
||||
/**
|
||||
* Extract persisted attachments from message content (type-safe with Zod)
|
||||
*/
|
||||
function extractPersistedAttachments(content: unknown): PersistedAttachment[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
|
||||
for (const part of content) {
|
||||
const result = AttachmentsPartSchema.safeParse(part);
|
||||
if (result.success) {
|
||||
return result.data.items;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert backend message to assistant-ui ThreadMessageLike format
|
||||
* Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps
|
||||
* Restores attachments for user messages from persisted data
|
||||
*/
|
||||
export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
||||
let content: ThreadMessageLike["content"];
|
||||
|
|
@ -52,7 +15,7 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
|||
const filteredContent = msg.content.filter((part: unknown) => {
|
||||
if (typeof part !== "object" || part === null || !("type" in part)) return true;
|
||||
const partType = (part as { type: string }).type;
|
||||
// Filter out thinking-steps, mentioned-documents, and attachments
|
||||
// Filter out metadata parts not directly renderable by assistant-ui
|
||||
return (
|
||||
partType !== "thinking-steps" &&
|
||||
partType !== "mentioned-documents" &&
|
||||
|
|
@ -67,25 +30,6 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
|||
content = [{ type: "text", text: String(msg.content) }];
|
||||
}
|
||||
|
||||
// Restore attachments for user messages
|
||||
let attachments: ThreadMessageLike["attachments"];
|
||||
if (msg.role === "user") {
|
||||
const persistedAttachments = extractPersistedAttachments(msg.content);
|
||||
if (persistedAttachments.length > 0) {
|
||||
attachments = persistedAttachments.map((att) => ({
|
||||
id: att.id,
|
||||
name: att.name,
|
||||
type: att.type as "document" | "image" | "file",
|
||||
contentType: att.contentType || "application/octet-stream",
|
||||
status: { type: "complete" as const },
|
||||
content: [],
|
||||
// Custom fields for our ChatAttachment interface
|
||||
imageDataUrl: att.imageDataUrl,
|
||||
extractedContent: att.extractedContent,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Build metadata.custom for author display in shared chats
|
||||
const metadata = msg.author_id
|
||||
? {
|
||||
|
|
@ -103,7 +47,6 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
|||
role: msg.role,
|
||||
content,
|
||||
createdAt: new Date(msg.created_at),
|
||||
attachments,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
// Helper function to get connector type display name
|
||||
export const getConnectorTypeDisplay = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
SERPER_API: "Serper API",
|
||||
TAVILY_API: "Tavily API",
|
||||
SEARXNG_API: "SearxNG",
|
||||
LINKUP_API: "Linkup",
|
||||
BAIDU_SEARCH_API: "Baidu Search",
|
||||
SLACK_CONNECTOR: "Slack",
|
||||
TEAMS_CONNECTOR: "Microsoft Teams",
|
||||
NOTION_CONNECTOR: "Notion",
|
||||
GITHUB_CONNECTOR: "GitHub",
|
||||
LINEAR_CONNECTOR: "Linear",
|
||||
JIRA_CONNECTOR: "Jira",
|
||||
DISCORD_CONNECTOR: "Discord",
|
||||
LINKUP_API: "Linkup",
|
||||
CONFLUENCE_CONNECTOR: "Confluence",
|
||||
BOOKSTACK_CONNECTOR: "BookStack",
|
||||
CLICKUP_CONNECTOR: "ClickUp",
|
||||
|
|
@ -23,8 +26,10 @@ export const getConnectorTypeDisplay = (type: string): string => {
|
|||
LUMA_CONNECTOR: "Luma",
|
||||
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
|
||||
WEBCRAWLER_CONNECTOR: "Web Pages",
|
||||
YOUTUBE_CONNECTOR: "YouTube",
|
||||
CIRCLEBACK_CONNECTOR: "Circleback",
|
||||
OBSIDIAN_CONNECTOR: "Obsidian",
|
||||
MCP_CONNECTOR: "MCP Server",
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "surfsense_web",
|
||||
"version": "0.0.12",
|
||||
"version": "0.0.13",
|
||||
"private": true,
|
||||
"description": "SurfSense Frontend",
|
||||
"scripts": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue