Merge upstream/dev into feat/vision-autocomplete

This commit is contained in:
CREDO23 2026-04-04 09:15:13 +02:00
commit d7315e7f27
142 changed files with 9440 additions and 3390 deletions

View file

@ -29,7 +29,7 @@ interface ChangelogPageItem {
export default async function ChangelogPage() {
const allPages = source.getPages() as ChangelogPageItem[];
const sortedChangelogs = allPages.sort((a, b) => {
const sortedChangelogs = allPages.toSorted((a, b) => {
const dateA = new Date(a.data.date).getTime();
const dateB = new Date(b.data.date).getTime();
return dateB - dateA;

View file

@ -160,10 +160,10 @@ export function LocalLoginForm() {
placeholder="you@example.com"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-background text-foreground transition-all ${
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-destructive focus:border-destructive focus:ring-destructive"
: "border-border focus:border-primary focus:ring-primary"
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
disabled={isLoggingIn}
/>
@ -181,10 +181,10 @@ export function LocalLoginForm() {
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-background text-foreground transition-all ${
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-destructive focus:border-destructive focus:ring-destructive"
: "border-border focus:border-primary focus:ring-primary"
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
disabled={isLoggingIn}
/>

View file

@ -115,7 +115,7 @@ function LoginContent() {
<div className="relative w-full overflow-hidden">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="h-16 w-16 md:h-32 md:w-32 rounded-md transition-all" />
<Logo priority className="h-16 w-16 md:h-32 md:w-32 rounded-md transition-all" />
<h1 className="mt-4 mb-6 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:mt-8 md:mb-8 md:text-3xl lg:text-4xl transition-all">
{t("sign_in")}
</h1>

View file

@ -160,7 +160,7 @@ export default function RegisterPage() {
<div className="relative w-full overflow-hidden">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center px-6 md:px-0">
<Logo className="h-16 w-16 md:h-32 md:w-32 rounded-md transition-all" />
<Logo priority className="h-16 w-16 md:h-32 md:w-32 rounded-md transition-all" />
<h1 className="mt-4 mb-6 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:mt-8 md:mb-8 md:text-3xl lg:text-4xl transition-all">
{t("create_account")}
</h1>
@ -229,10 +229,7 @@ export default function RegisterPage() {
</AnimatePresence>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
{t("email")}
</label>
<input
@ -242,20 +239,17 @@ export default function RegisterPage() {
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
disabled={isRegistering}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
{t("password")}
</label>
<input
@ -265,10 +259,10 @@ export default function RegisterPage() {
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
disabled={isRegistering}
/>
@ -277,7 +271,7 @@ export default function RegisterPage() {
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
className="block text-sm font-medium text-foreground"
>
{t("confirm_password")}
</label>
@ -288,10 +282,10 @@ export default function RegisterPage() {
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
disabled={isRegistering}
/>
@ -300,7 +294,7 @@ export default function RegisterPage() {
<button
type="submit"
disabled={isRegistering}
className="relative w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
className="relative w-full rounded-md bg-primary px-4 py-1.5 md:py-2 text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
>
<span className={isRegistering ? "invisible" : ""}>{t("register")}</span>
{isRegistering && (
@ -312,12 +306,9 @@ export default function RegisterPage() {
</form>
<div className="mt-4 text-center text-sm">
<p className="text-gray-600 dark:text-gray-400">
<p className="text-muted-foreground">
{t("already_have_account")}{" "}
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
<Link href="/login" className="font-medium text-primary hover:text-primary/90">
{t("sign_in")}
</Link>
</p>

View file

@ -17,6 +17,7 @@ import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document
import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useFolderSync } from "@/hooks/use-folder-sync";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
export function DashboardClientLayout({
@ -159,6 +160,9 @@ export function DashboardClientLayout({
// Use global loading screen - spinner animation won't reset
useGlobalLoadingEffect(shouldShowLoading);
// Wire desktop app file watcher -> single-file re-index API
useFolderSync();
if (shouldShowLoading) {
return null;
}

View file

@ -35,6 +35,7 @@ export function getDocumentTypeLabel(type: string): string {
BOOKSTACK_CONNECTOR: "BookStack",
CIRCLEBACK: "Circleback",
OBSIDIAN_CONNECTOR: "Obsidian",
LOCAL_FOLDER_FILE: "Local Folder",
SURFSENSE_DOCS: "SurfSense Docs",
NOTE: "Note",
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Composio Google Drive",

View file

@ -267,12 +267,23 @@ export function DocumentsTableShell({
const [metadataJson, setMetadataJson] = useState<Record<string, unknown> | null>(null);
const [metadataLoading, setMetadataLoading] = useState(false);
const [previewScrollPos, setPreviewScrollPos] = useState<"top" | "middle" | "bottom">("top");
const previewRafRef = useRef<number>();
const handlePreviewScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setPreviewScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
if (previewRafRef.current) return;
previewRafRef.current = requestAnimationFrame(() => {
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setPreviewScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
previewRafRef.current = undefined;
});
}, []);
useEffect(
() => () => {
if (previewRafRef.current) cancelAnimationFrame(previewRafRef.current);
},
[]
);
const [deleteDoc, setDeleteDoc] = useState<Document | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
@ -329,14 +340,15 @@ export function DocumentsTableShell({
const handleViewDocument = useCallback(async (doc: Document) => {
setViewingDoc(doc);
if (doc.content) {
setViewingContent(doc.content);
const preview = doc.content_preview || doc.content;
if (preview) {
setViewingContent(preview);
return;
}
setViewingLoading(true);
try {
const fullDoc = await documentsApiService.getDocument({ id: doc.id });
setViewingContent(fullDoc.content);
setViewingContent(fullDoc.content_preview || fullDoc.content);
} catch (err) {
console.error("[DocumentsTableShell] Failed to fetch document content:", err);
setViewingContent("Failed to load document content.");
@ -630,7 +642,7 @@ export function DocumentsTableShell({
return (
<tr
key={doc.id}
className={`group border-b border-border/50 transition-colors ${
className={`list-item-lazy group border-b border-border/50 transition-colors ${
isMentioned ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30"
} ${canInteract && hasChatMode ? "cursor-pointer" : ""}`}
onClick={handleRowClick}
@ -748,6 +760,7 @@ export function DocumentsTableShell({
onClick={() =>
onOpenInTab ? onOpenInTab(doc) : handleViewDocument(doc)
}
disabled={isBeingProcessed}
>
<Eye className="h-4 w-4" />
Open
@ -871,7 +884,7 @@ export function DocumentsTableShell({
return (
<MobileCardWrapper key={doc.id} onLongPress={() => setMobileActionDoc(doc)}>
<div
className={`relative px-3 py-2 transition-colors ${
className={`list-item-lazy relative px-3 py-2 transition-colors ${
isMentioned ? "bg-primary/5" : "hover:bg-muted/20"
} ${canInteract && hasChatMode ? "cursor-pointer" : ""}`}
>
@ -951,7 +964,30 @@ export function DocumentsTableShell({
<Spinner size="lg" className="text-muted-foreground" />
</div>
) : (
<MarkdownViewer content={viewingContent} />
<>
<MarkdownViewer content={viewingContent} maxLength={50_000} />
{viewingDoc && (
<div className="mt-4 flex justify-center">
<Button
variant="outline"
size="sm"
onClick={() => {
if (viewingDoc) {
openEditor({
documentId: viewingDoc.id,
searchSpaceId: Number(searchSpaceId),
title: viewingDoc.title,
});
handleCloseViewer();
}
}}
>
<Eye className="h-3.5 w-3.5 mr-1.5" />
View full document
</Button>
</div>
)}
</>
)}
</div>
</DrawerContent>
@ -1020,6 +1056,10 @@ export function DocumentsTableShell({
<Button
variant="secondary"
className="justify-start gap-2"
disabled={
mobileActionDoc?.status?.state === "pending" ||
mobileActionDoc?.status?.state === "processing"
}
onClick={() => {
if (mobileActionDoc) handleViewDocument(mobileActionDoc);
setMobileActionDoc(null);

View file

@ -9,9 +9,9 @@ export type Document = {
id: number;
title: string;
document_type: DocumentType;
// Optional: Only needed when viewing document details (lazy loaded)
document_metadata?: any;
content?: string;
content_preview?: string;
created_at: string;
search_space_id: number;
created_by_id?: string | null;

View file

@ -8,7 +8,7 @@ import {
} from "@assistant-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { useParams, useSearchParams } from "next/navigation";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
@ -228,13 +228,14 @@ export default function NewChatPage() {
return prev;
}
const memberById = new Map(membersData?.map((m) => [m.user_id, m]) ?? []);
const prevById = new Map(prev.map((m) => [m.id, m]));
return syncedMessages.map((msg) => {
const member = msg.author_id
? membersData?.find((m) => m.user_id === msg.author_id)
: null;
const member = msg.author_id ? (memberById.get(msg.author_id) ?? null) : null;
// Preserve existing author info if member lookup fails (e.g., cloned chats)
const existingMsg = prev.find((m) => m.id === `msg-${msg.id}`);
const existingMsg = prevById.get(`msg-${msg.id}`);
const existingAuthor = existingMsg?.metadata?.custom?.author as
| { displayName?: string | null; avatarUrl?: string | null }
| undefined;
@ -388,22 +389,32 @@ export default function NewChatPage() {
}, [searchSpaceId, queryClient]);
// Handle scroll to comment from URL query params (e.g., from inbox item click)
const searchParams = useSearchParams();
const targetCommentIdParam = searchParams.get("commentId");
// Set target comment ID from URL param - the AssistantMessage and CommentItem
// components will handle scrolling and highlighting once comments are loaded
// Read from window.location.search inside the effect instead of subscribing via
// useSearchParams() — avoids re-rendering this heavy component tree on every
// unrelated query-string change. (Vercel Best Practice: rerender-defer-reads 5.2)
useEffect(() => {
if (targetCommentIdParam && !isInitializing) {
const commentId = Number.parseInt(targetCommentIdParam, 10);
if (!Number.isNaN(commentId)) {
setTargetCommentId(commentId);
const readAndApplyCommentId = () => {
const params = new URLSearchParams(window.location.search);
const raw = params.get("commentId");
if (raw && !isInitializing) {
const commentId = Number.parseInt(raw, 10);
if (!Number.isNaN(commentId)) {
setTargetCommentId(commentId);
}
}
}
};
readAndApplyCommentId();
// Also respond to SPA navigations (back/forward) that change the query string
window.addEventListener("popstate", readAndApplyCommentId);
// Cleanup on unmount or when navigating away
return () => clearTargetCommentId();
}, [targetCommentIdParam, isInitializing, setTargetCommentId, clearTargetCommentId]);
return () => {
window.removeEventListener("popstate", readAndApplyCommentId);
clearTargetCommentId();
};
}, [isInitializing, setTargetCommentId, clearTargetCommentId]);
// Sync current thread state to atom
useEffect(() => {

View file

@ -60,7 +60,7 @@ export function CommunityPromptsContent() {
{list.length === 0 && (
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
<Globe className="mx-auto size-8 text-muted-foreground/40" />
<Globe className="mx-auto size-8 text-muted-foreground" />
<p className="mt-2 text-sm text-muted-foreground">No community prompts yet</p>
<p className="text-xs text-muted-foreground/60">
Share your own prompts from the My Prompts tab

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertTriangle, Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
import { AlertTriangle, Globe, Lock, PenLine, Sparkles, Trash2 } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
@ -23,6 +23,7 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import type { PromptRead } from "@/contracts/types/prompts.types";
@ -144,9 +145,8 @@ export function PromptsContent() {
<div className="space-y-6 min-w-0 overflow-hidden">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Create prompt templates triggered with{" "}
<kbd className="rounded border bg-muted px-1.5 py-0.5 text-xs font-mono">/</kbd> in the
chat composer.
Create prompt templates triggered with <ShortcutKbd keys={["/"]} className="ml-0" /> in
the chat composer.
</p>
{!showForm && (
<Button
@ -158,7 +158,6 @@ export function PromptsContent() {
}}
className="shrink-0 gap-1.5"
>
<Plus className="size-3.5" />
New
</Button>
)}

View file

@ -3,7 +3,7 @@
import { useAtomValue } from "jotai";
import { AlertCircle, Plus, Search } from "lucide-react";
import { motion } from "motion/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
@ -89,7 +89,6 @@ function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
export default function DashboardPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const t = useTranslations("dashboard");
@ -99,11 +98,12 @@ export default function DashboardPage() {
if (isLoading) return;
if (searchSpaces.length > 0) {
const params = searchParams.toString();
const query = params ? `?${params}` : "";
// Read the query string at the time of redirect — no subscription needed.
// (Vercel Best Practice: rerender-defer-reads 5.2)
const query = window.location.search;
router.replace(`/dashboard/${searchSpaces[0].id}/new-chat${query}`);
}
}, [isLoading, searchSpaces, router, searchParams]);
}, [isLoading, searchSpaces, router]);
// Show loading while fetching or while we have spaces and are about to redirect
const shouldShowLoading = isLoading || searchSpaces.length > 0;

View file

@ -1,11 +1,16 @@
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/page";
import { notFound } from "next/navigation";
import { cache } from "react";
import { source } from "@/lib/source";
import { getMDXComponents } from "@/mdx-components";
const getDocPage = cache((slug?: string[]) => {
return source.getPage(slug);
});
export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
const params = await props.params;
const page = source.getPage(params.slug);
const page = getDocPage(params.slug);
if (!page) notFound();
const MDX = page.data.body;
@ -37,7 +42,7 @@ export async function generateStaticParams() {
export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }) {
const params = await props.params;
const page = source.getPage(params.slug);
const page = getDocPage(params.slug);
if (!page) notFound();
return {

View file

@ -1,6 +1,5 @@
"use client";
import posthog from "posthog-js";
import { useEffect } from "react";
export default function ErrorPage({
@ -11,7 +10,11 @@ export default function ErrorPage({
reset: () => void;
}) {
useEffect(() => {
posthog.captureException(error);
import("posthog-js")
.then(({ default: posthog }) => {
posthog.captureException(error);
})
.catch(() => {});
}, [error]);
return (

View file

@ -246,6 +246,17 @@ button {
}
}
/* content-visibility utilities — skip layout/paint for off-screen list items */
.list-item-lazy {
content-visibility: auto;
contain-intrinsic-size: 0 48px;
}
.sidebar-item-lazy {
content-visibility: auto;
contain-intrinsic-size: 0 40px;
}
@source "../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}";
@source "../node_modules/streamdown/dist/*.js";
@source "../node_modules/@streamdown/code/dist/*.js";

View file

@ -34,7 +34,7 @@ export const createNewLLMConfigMutationAtom = atomWithMutation((get) => {
});
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create LLM model");
toast.error(error.message || "Failed to create model");
},
};
});
@ -109,10 +109,11 @@ export const updateLLMPreferencesMutationAtom = atomWithMutation((get) => {
mutationFn: async (request: UpdateLLMPreferencesRequest) => {
return newLLMConfigApiService.updateLLMPreferences(request);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)),
});
onSuccess: (_data, request: UpdateLLMPreferencesRequest) => {
queryClient.setQueryData(
cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)),
(old: Record<string, unknown> | undefined) => ({ ...old, ...request.data })
);
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update LLM preferences");

View file

@ -66,7 +66,7 @@ export const defaultSystemInstructionsAtom = atomWithQuery(() => {
});
/**
* Query atom for the dynamic LLM model catalogue.
* Query atom for the dynamic model catalogue.
* Fetched from the backend (which proxies OpenRouter's public API).
* Falls back to the static hardcoded list on error.
*/

View file

@ -5,9 +5,11 @@ import { cn } from "@/lib/utils";
export const Logo = ({
className,
disableLink = false,
priority = false,
}: {
className?: string;
disableLink?: boolean;
priority?: boolean;
}) => {
const image = (
<Image
@ -16,6 +18,7 @@ export const Logo = ({
alt="logo"
width={128}
height={128}
priority={priority}
/>
);

View file

@ -1,6 +1,5 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils";
@ -26,8 +25,6 @@ const TokenHandler = ({
tokenParamName = "token",
storageKey = "surfsense_bearer_token",
}: TokenHandlerProps) => {
const searchParams = useSearchParams();
// Always show loading for this component - spinner animation won't reset
useGlobalLoadingEffect(true);
@ -35,9 +32,13 @@ const TokenHandler = ({
// Only run on client-side
if (typeof window === "undefined") return;
// Get tokens from URL parameters
const token = searchParams.get(tokenParamName);
const refreshToken = searchParams.get("refresh_token");
// Read tokens from URL at mount time — no subscription needed.
// TokenHandler only runs once after an auth redirect, so a stale read
// is impossible and useSearchParams() would add a pointless subscription.
// (Vercel Best Practice: rerender-defer-reads 5.2)
const params = new URLSearchParams(window.location.search);
const token = params.get(tokenParamName);
const refreshToken = params.get("refresh_token");
if (token) {
try {
@ -74,7 +75,7 @@ const TokenHandler = ({
window.location.href = redirectPath;
}
}
}, [searchParams, tokenParamName, storageKey, redirectPath]);
}, [tokenParamName, storageKey, redirectPath]);
// Return null - the global provider handles the loading UI
return null;

View file

@ -11,7 +11,6 @@ import {
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
@ -47,7 +46,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
(_props, ref) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
useAtomValue(currentUserAtom);
const { data: preferences = {}, isFetching: preferencesLoading } =
useAtomValue(llmPreferencesAtom);
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
@ -376,14 +374,17 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
{/* LLM Configuration Warning */}
{!llmConfigLoading && !hasDocumentSummaryLLM && (
<Alert variant="destructive" className="mb-6">
<Alert
variant="destructive"
className="mb-6 bg-muted/50 rounded-xl border-destructive/30"
>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>LLM Configuration Required</AlertTitle>
<AlertDescription className="mt-2">
<p className="mb-3">
{isAutoMode && !hasGlobalConfigs
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize documents from your connected sources."
: "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}
? "Auto mode requires a global LLM configuration. Please add one in Settings"
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
</p>
<Button
size="sm"

View file

@ -58,7 +58,6 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo
return MCPConnectForm;
case "OBSIDIAN_CONNECTOR":
return ObsidianConnectForm;
// Add other connector types here as needed
default:
return null;
}

View file

@ -34,9 +34,12 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
const [isLoading, setIsLoading] = useState(true);
const [copied, setCopied] = useState(false);
// Fetch webhook info
// Fetch webhook info
useEffect(() => {
const fetchWebhookInfo = async () => {
const controller = new AbortController();
const doFetch = async () => {
if (!connector.search_space_id) return;
const baseUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL;
@ -49,8 +52,11 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
setIsLoading(true);
try {
const response = await authenticatedFetch(
`${baseUrl}/api/v1/webhooks/circleback/${connector.search_space_id}/info`
`${baseUrl}/api/v1/webhooks/circleback/${connector.search_space_id}/info`,
{ signal: controller.signal }
);
if (controller.signal.aborted) return;
if (response.ok) {
const data: unknown = await response.json();
// Runtime validation with zod schema
@ -59,16 +65,18 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
setWebhookUrl(validatedData.webhook_url);
}
} catch (error) {
if (controller.signal.aborted) return;
console.error("Failed to fetch webhook info:", error);
// Reset state on error
setWebhookInfo(null);
setWebhookUrl("");
} finally {
setIsLoading(false);
if (!controller.signal.aborted) setIsLoading(false);
}
};
fetchWebhookInfo();
doFetch().catch(() => {});
return () => controller.abort();
}, [connector.search_space_id]);
const handleNameChange = (value: string) => {

View file

@ -272,7 +272,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* AI Summary toggle */}
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, or GitHub (indexes full repo snapshots) */}
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */}
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
connector.connector_type !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
connector.connector_type !== "DROPBOX_CONNECTOR" &&
@ -293,9 +293,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
/>
)}
{/* Periodic sync - shown for all indexable connectors */}
{(() => {
// Check if Google Drive (regular or Composio) has folders/files selected
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
const isComposioGoogleDrive =
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";

View file

@ -158,7 +158,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
{/* AI Summary toggle */}
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, or GitHub (indexes full repo snapshots) */}
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "DROPBOX_CONNECTOR" &&
@ -179,9 +179,10 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
/>
)}
{/* Periodic sync - not shown for Google Drive (regular and Composio) */}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" && (
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "DROPBOX_CONNECTOR" &&
config.connectorType !== "ONEDRIVE_CONNECTOR" && (
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}

View file

@ -76,29 +76,26 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
}) => {
// Check if self-hosted mode (for showing self-hosted only connectors)
const selfHosted = isSelfHosted();
const isDesktop = typeof window !== "undefined" && !!window.electronAPI;
const matchesSearch = (title: string, description: string) =>
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
description.toLowerCase().includes(searchQuery.toLowerCase());
const passesDeploymentFilter = (c: { selfHostedOnly?: boolean; desktopOnly?: boolean }) =>
(!c.selfHostedOnly || selfHosted) && (!c.desktopOnly || isDesktop);
// Filter connectors based on search and deployment mode
const filteredOAuth = OAUTH_CONNECTORS.filter(
(c) =>
// Filter by search query
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
// Filter self-hosted only connectors in cloud mode
(!("selfHostedOnly" in c) || !c.selfHostedOnly || selfHosted)
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
const filteredCrawlers = CRAWLERS.filter(
(c) =>
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
(!("selfHostedOnly" in c) || !c.selfHostedOnly || selfHosted)
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
const filteredOther = OTHER_CONNECTORS.filter(
(c) =>
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
(!("selfHostedOnly" in c) || !c.selfHostedOnly || selfHosted)
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
// Filter Composio connectors

View file

@ -125,38 +125,35 @@ const DocumentUploadPopupContent: FC<{
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"
className="select-none max-w-2xl w-[95vw] sm:w-[640px] h-[min(440px,75dvh)] sm:h-[min(500px,80vh)] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-6 [&>button]:top-3 sm:[&>button]:top-5 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"
>
<DialogTitle className="sr-only">Upload Document</DialogTitle>
{/* Scrollable container for mobile */}
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain">
{/* Header - scrolls with content on mobile */}
<div className="sticky top-0 z-20 bg-muted px-4 sm:px-12 pt-4 sm:pt-10 pb-2 sm:pb-0">
{/* Upload header */}
<div className="flex items-center gap-2 sm:gap-4 mb-2 sm:mb-6">
<div className="flex-1 min-w-0 pr-8 sm:pr-0">
<h2 className="text-base sm:text-2xl font-semibold tracking-tight">
Upload Documents
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-0.5 sm:mt-1 line-clamp-1 sm:line-clamp-none">
Upload and sync your documents to your search space
</p>
</div>
<div className="sticky top-0 z-20 bg-muted px-4 sm:px-6 pt-4 sm:pt-5 pb-10">
<div className="flex items-center gap-2 mb-1 pr-8 sm:pr-0">
<h2 className="text-base sm:text-lg font-semibold tracking-tight">
Upload Documents
</h2>
</div>
<p className="text-xs sm:text-sm text-muted-foreground line-clamp-1">
Upload and sync your documents to your search space
</p>
</div>
{/* Content */}
<div className="px-4 sm:px-12 pb-4 sm:pb-16">
<div className="px-4 sm:px-6 pb-4 sm:pb-6">
{!isLoading && !hasDocumentSummaryLLM ? (
<Alert variant="destructive" className="mb-4">
<Alert
variant="destructive"
className="mb-4 bg-muted/50 rounded-xl border-destructive/30"
>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>LLM Configuration Required</AlertTitle>
<AlertDescription className="mt-2">
<p className="mb-3">
{isAutoMode && !hasGlobalConfigs
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize your uploaded documents."
: "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."}
? "Auto mode requires a global LLM configuration. Please add one in Settings"
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
</p>
<Button
size="sm"
@ -179,9 +176,6 @@ const DocumentUploadPopupContent: FC<{
)}
</div>
</div>
{/* Bottom fade shadow - hidden on very small screens */}
<div className="hidden sm:block absolute bottom-0 left-0 right-0 h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
</DialogContent>
</Dialog>
);

View file

@ -6,6 +6,7 @@ import { ImageIcon, ImageOffIcon } from "lucide-react";
import { memo, type PropsWithChildren, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import NextImage from 'next/image';
const imageVariants = cva("aui-image-root relative overflow-hidden rounded-lg", {
variants: {
@ -86,23 +87,57 @@ function ImagePreview({
>
<ImageOffIcon className="size-8 text-muted-foreground" />
</div>
) : (
) : isDataOrBlobUrl(src) ? (
// biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
<img
ref={imgRef}
src={src}
alt={alt}
className={cn("block h-auto w-full object-contain", !loaded && "invisible", className)}
onLoad={(e) => {
if (typeof src === "string") setLoadedSrc(src);
onLoad?.(e);
}}
onError={(e) => {
if (typeof src === "string") setErrorSrc(src);
onError?.(e);
}}
{...props}
/>
) : (
// biome-ignore lint/performance/noImgElement: intentional for dynamic external URLs
<img
ref={imgRef}
src={src}
alt={alt}
className={cn("block h-auto w-full object-contain", !loaded && "invisible", className)}
onLoad={(e) => {
if (typeof src === "string") setLoadedSrc(src);
onLoad?.(e);
}}
onError={(e) => {
if (typeof src === "string") setErrorSrc(src);
onError?.(e);
}}
{...props}
/>
// <img
// ref={imgRef}
// src={src}
// alt={alt}
// className={cn("block h-auto w-full object-contain", !loaded && "invisible", className)}
// onLoad={(e) => {
// if (typeof src === "string") setLoadedSrc(src);
// onLoad?.(e);
// }}
// onError={(e) => {
// if (typeof src === "string") setErrorSrc(src);
// onError?.(e);
// }}
// {...props}
// />
<NextImage
fill
src={src || ""}
alt={alt}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 60vw"
className={cn("block object-contain", !loaded && "invisible", className)}
onLoad={() => {
if (typeof src === "string") setLoadedSrc(src);
onLoad?.();
}}
onError={() => {
if (typeof src === "string") setErrorSrc(src);
onError?.();
}}
unoptimized={false}
{...props}
/>
)}
</div>
);
@ -126,7 +161,10 @@ type ImageZoomProps = PropsWithChildren<{
src: string;
alt?: string;
}>;
function isDataOrBlobUrl(src: string | undefined): boolean {
if (!src || typeof src !== "string") return false;
return src.startsWith("data:") || src.startsWith("blob:");
}
function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
const [isMounted, setIsMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false);
@ -177,22 +215,39 @@ function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
aria-label="Close zoomed image"
>
{/** biome-ignore lint/performance/noImgElement: <explanation> */}
<img
data-slot="image-zoom-content"
src={src}
alt={alt}
className="aui-image-zoom-content fade-in zoom-in-95 max-h-[90vh] max-w-[90vw] animate-in object-contain duration-200"
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.stopPropagation();
handleClose();
}
}}
/>
{isDataOrBlobUrl(src) ? (
// biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
<img
data-slot="image-zoom-content"
src={src}
alt={alt}
className="aui-image-zoom-content fade-in zoom-in-95 max-h-[90vh] max-w-[90vw] animate-in object-contain duration-200"
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.stopPropagation();
handleClose();
}
}}
/>
) : (
<NextImage
data-slot="image-zoom-content"
fill
src={src}
alt={alt}
sizes="90vw"
className="aui-image-zoom-content fade-in zoom-in-95 object-contain duration-200"
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
unoptimized={false}
/>
)}
</button>,
document.body
)}

View file

@ -32,7 +32,7 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
<button
type="button"
onClick={() => setIsOpen(true)}
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-super shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
title={`View source chunk #${chunkId}`}
>
{chunkId}

View file

@ -15,6 +15,7 @@ import {
ChevronDown,
ChevronUp,
Clipboard,
Dot,
Globe,
Plus,
Settings2,
@ -816,12 +817,23 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const isDesktop = useMediaQuery("(min-width: 640px)");
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const [toolsScrollPos, setToolsScrollPos] = useState<"top" | "middle" | "bottom">("top");
const toolsRafRef = useRef<number>();
const handleToolsScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setToolsScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
if (toolsRafRef.current) return;
toolsRafRef.current = requestAnimationFrame(() => {
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setToolsScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
toolsRafRef.current = undefined;
});
}, []);
useEffect(
() => () => {
if (toolsRafRef.current) cancelAnimationFrame(toolsRafRef.current);
},
[]
);
const isComposerTextEmpty = useAuiState(({ composer }) => {
const text = composer.text?.trim() || "";
return text.length === 0;
@ -834,6 +846,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const { data: agentTools } = useAtomValue(agentToolsAtom);
const disabledTools = useAtomValue(disabledToolsAtom);
const disabledToolsSet = useMemo(() => new Set(disabledTools), [disabledTools]);
const toggleTool = useSetAtom(toggleToolAtom);
const setDisabledTools = useSetAtom(disabledToolsAtom);
const hydrateDisabled = useSetAtom(hydrateDisabledToolsAtom);
@ -846,18 +859,18 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const toggleToolGroup = useCallback(
(toolNames: string[]) => {
const allDisabled = toolNames.every((name) => disabledTools.includes(name));
const allDisabled = toolNames.every((name) => disabledToolsSet.has(name));
if (allDisabled) {
setDisabledTools((prev) => prev.filter((t) => !toolNames.includes(t)));
} else {
setDisabledTools((prev) => [...new Set([...prev, ...toolNames])]);
}
},
[disabledTools, setDisabledTools]
[disabledToolsSet, setDisabledTools]
);
const hasWebSearchTool = agentTools?.some((t) => t.name === "web_search") ?? false;
const isWebSearchEnabled = hasWebSearchTool && !disabledTools.includes("web_search");
const isWebSearchEnabled = hasWebSearchTool && !disabledToolsSet.has("web_search");
const filteredTools = useMemo(
() => agentTools?.filter((t) => t.name !== "web_search"),
[agentTools]
@ -957,7 +970,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
{group.label}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledTools.includes(tool.name);
const isDisabled = disabledToolsSet.has(tool.name);
const ToolIcon = getToolIcon(tool.name);
return (
<div
@ -989,7 +1002,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const iconKey = group.connectorIcon ?? "";
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
const toolNames = group.tools.map((t) => t.name);
const allDisabled = toolNames.every((n) => disabledTools.includes(n));
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
return (
<div
key={group.label}
@ -1063,7 +1076,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
>
<div className="sr-only">Manage Tools</div>
<div
className="max-h-48 sm:max-h-64 overflow-y-auto py-0.5 sm:py-1"
className="max-h-48 sm:max-h-64 overflow-y-auto overscroll-none py-0.5 sm:py-1"
onScroll={handleToolsScroll}
style={{
maskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`,
@ -1078,7 +1091,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
{group.label}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledTools.includes(tool.name);
const isDisabled = disabledToolsSet.has(tool.name);
const ToolIcon = getToolIcon(tool.name);
const row = (
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
@ -1115,7 +1128,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const iconKey = group.connectorIcon ?? "";
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
const toolNames = group.tools.map((t) => t.name);
const allDisabled = toolNames.every((n) => disabledTools.includes(n));
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
const groupDef = TOOL_GROUPS.find((g) => g.label === group.label);
const row = (
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
@ -1146,7 +1159,11 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="right" className="max-w-72 text-xs">
{groupDef?.tooltip ??
group.tools.map((t) => t.description).join(" · ")}
group.tools.flatMap((t, i) =>
i === 0
? [t.description]
: [<Dot key={i} className="inline h-4 w-4" />, t.description]
)}
</TooltipContent>
</Tooltip>
);

View file

@ -1,6 +1,6 @@
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { getToolIcon } from "@/contracts/enums/toolIcons";
import { cn } from "@/lib/utils";
@ -19,17 +19,28 @@ export const ToolFallback: ToolCallMessagePartComponent = ({
const isCancelled = status?.type === "incomplete" && status.reason === "cancelled";
const isError = status?.type === "incomplete" && status.reason === "error";
const isRunning = status?.type === "running" || status?.type === "requires-action";
const errorData = status?.type === "incomplete" ? status.error : undefined;
const serializedError = useMemo(
() => (errorData && typeof errorData !== "string" ? JSON.stringify(errorData) : null),
[errorData]
);
const serializedResult = useMemo(
() => (result !== undefined && typeof result !== "string" ? JSON.stringify(result, null, 2) : null),
[result]
);
const cancelledReason =
isCancelled && status.error
? typeof status.error === "string"
? status.error
: JSON.stringify(status.error)
: serializedError
: null;
const errorReason =
isError && status.error
? typeof status.error === "string"
? status.error
: JSON.stringify(status.error)
: serializedError
: null;
const Icon = getToolIcon(toolName);
@ -122,7 +133,7 @@ export const ToolFallback: ToolCallMessagePartComponent = ({
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Result</p>
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
{typeof result === "string" ? result : serializedResult}
</pre>
</div>
</>

View file

@ -15,13 +15,14 @@ function convertDisplayToData(displayContent: string, mentions: InsertedMention[
const sortedMentions = [...mentions].sort((a, b) => b.displayName.length - a.displayName.length);
for (const mention of sortedMentions) {
const displayPattern = new RegExp(
`@${escapeRegExp(mention.displayName)}(?=\\s|$|[.,!?;:])`,
"g"
);
const dataFormat = `@[${mention.id}]`;
result = result.replace(displayPattern, dataFormat);
const mentionPatterns = sortedMentions.map((mention) => ({
pattern: new RegExp(`@${escapeRegExp(mention.displayName)}(?=\\s|$|[.,!?;:])`, "g"),
dataFormat: `@[${mention.id}]`,
}));
for (const { pattern, dataFormat } of mentionPatterns) {
pattern.lastIndex = 0; // reset global regex state
result = result.replace(pattern, dataFormat);
}
return result;

View file

@ -5,6 +5,7 @@ import {
Clock,
Download,
Eye,
History,
MoreHorizontal,
Move,
PenLine,
@ -39,6 +40,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { cn } from "@/lib/utils";
import { DND_TYPES } from "./FolderNode";
import { isVersionableType } from "./version-history";
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
@ -60,6 +62,7 @@ interface DocumentNodeProps {
onDelete: (doc: DocumentNodeDoc) => void;
onMove: (doc: DocumentNodeDoc) => void;
onExport?: (doc: DocumentNodeDoc, format: string) => void;
onVersionHistory?: (doc: DocumentNodeDoc) => void;
contextMenuOpen?: boolean;
onContextMenuOpenChange?: (open: boolean) => void;
}
@ -74,6 +77,7 @@ export const DocumentNode = React.memo(function DocumentNode({
onDelete,
onMove,
onExport,
onVersionHistory,
contextMenuOpen,
onContextMenuOpenChange,
}: DocumentNodeProps) {
@ -195,12 +199,17 @@ export const DocumentNode = React.memo(function DocumentNode({
<span className="flex-1 min-w-0 truncate">{doc.title}</span>
<span className="shrink-0">
{getDocumentTypeIcon(
doc.document_type as DocumentTypeEnum,
"h-3.5 w-3.5 text-muted-foreground"
)}
</span>
{getDocumentTypeIcon(
doc.document_type as DocumentTypeEnum,
"h-3.5 w-3.5 text-muted-foreground"
) && (
<span className="shrink-0">
{getDocumentTypeIcon(
doc.document_type as DocumentTypeEnum,
"h-3.5 w-3.5 text-muted-foreground"
)}
</span>
)}
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
@ -219,7 +228,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={() => onPreview(doc)}>
<DropdownMenuItem onClick={() => onPreview(doc)} disabled={isProcessing}>
<Eye className="mr-2 h-4 w-4" />
Open
</DropdownMenuItem>
@ -235,7 +244,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</DropdownMenuItem>
{onExport && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<DropdownMenuSubTrigger disabled={isProcessing}>
<Download className="mr-2 h-4 w-4" />
Export
</DropdownMenuSubTrigger>
@ -244,6 +253,12 @@ export const DocumentNode = React.memo(function DocumentNode({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{onVersionHistory && isVersionableType(doc.document_type) && (
<DropdownMenuItem disabled={isProcessing} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isProcessing}
@ -259,7 +274,7 @@ export const DocumentNode = React.memo(function DocumentNode({
{contextMenuOpen && (
<ContextMenuContent className="w-40" onClick={(e) => e.stopPropagation()}>
<ContextMenuItem onClick={() => onPreview(doc)}>
<ContextMenuItem onClick={() => onPreview(doc)} disabled={isProcessing}>
<Eye className="mr-2 h-4 w-4" />
Open
</ContextMenuItem>
@ -275,7 +290,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</ContextMenuItem>
{onExport && (
<ContextMenuSub>
<ContextMenuSubTrigger>
<ContextMenuSubTrigger disabled={isProcessing}>
<Download className="mr-2 h-4 w-4" />
Export
</ContextMenuSubTrigger>
@ -284,6 +299,12 @@ export const DocumentNode = React.memo(function DocumentNode({
</ContextMenuSubContent>
</ContextMenuSub>
)}
{onVersionHistory && isVersionableType(doc.document_type) && (
<ContextMenuItem disabled={isProcessing} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</ContextMenuItem>
)}
<ContextMenuItem
className="text-destructive focus:text-destructive"
disabled={isProcessing}

View file

@ -1,14 +1,18 @@
"use client";
import {
AlertCircle,
ChevronDown,
ChevronRight,
Eye,
EyeOff,
Folder,
FolderOpen,
FolderPlus,
MoreHorizontal,
Move,
PenLine,
RefreshCw,
Trash2,
} from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
@ -27,6 +31,8 @@ import {
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 type { FolderSelectionState } from "./FolderTreeView";
@ -52,6 +58,7 @@ interface FolderNodeProps {
isRenaming: boolean;
childCount: number;
selectionState: FolderSelectionState;
processingState: "idle" | "processing" | "failed";
onToggleSelect: (folderId: number, selectAll: boolean) => void;
onToggleExpand: (folderId: number) => void;
onRename: (folder: FolderDisplay, newName: string) => void;
@ -70,6 +77,9 @@ interface FolderNodeProps {
disabledDropIds?: Set<number>;
contextMenuOpen?: boolean;
onContextMenuOpenChange?: (open: boolean) => void;
isWatched?: boolean;
onRescan?: (folder: FolderDisplay) => void;
onStopWatching?: (folder: FolderDisplay) => void;
}
function getDropZone(
@ -93,6 +103,7 @@ export const FolderNode = React.memo(function FolderNode({
isRenaming,
childCount,
selectionState,
processingState,
onToggleSelect,
onToggleExpand,
onRename,
@ -107,6 +118,9 @@ export const FolderNode = React.memo(function FolderNode({
disabledDropIds,
contextMenuOpen,
onContextMenuOpenChange,
isWatched,
onRescan,
onStopWatching,
}: FolderNodeProps) {
const [renameValue, setRenameValue] = useState(folder.name);
const inputRef = useRef<HTMLInputElement>(null);
@ -242,7 +256,9 @@ export const FolderNode = React.memo(function FolderNode({
isOver && !canDrop && "cursor-not-allowed"
)}
style={{ paddingLeft: `${depth * 16 + 4}px` }}
onClick={() => onToggleExpand(folder.id)}
onClick={() => {
onToggleExpand(folder.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
@ -262,14 +278,45 @@ export const FolderNode = React.memo(function FolderNode({
)}
</span>
<Checkbox
checked={
selectionState === "all" ? true : selectionState === "some" ? "indeterminate" : false
}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5 shrink-0"
/>
{processingState !== "idle" && selectionState === "none" ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center group-hover:hidden">
{processingState === "processing" ? (
<Spinner size="xs" className="text-primary" />
) : (
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
)}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{processingState === "processing"
? "Syncing folder contents"
: "Some files failed to process"}
</TooltipContent>
</Tooltip>
<Checkbox
checked={false}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5 shrink-0 hidden group-hover:flex"
/>
</>
) : (
<Checkbox
checked={
selectionState === "all"
? true
: selectionState === "some"
? "indeterminate"
: false
}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5 shrink-0"
/>
)}
<FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
@ -308,6 +355,28 @@ export const FolderNode = React.memo(function FolderNode({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{isWatched && onRescan && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onRescan(folder);
}}
>
<RefreshCw className="mr-2 h-4 w-4" />
Re-scan
</DropdownMenuItem>
)}
{isWatched && onStopWatching && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStopWatching(folder);
}}
>
<EyeOff className="mr-2 h-4 w-4" />
Stop watching
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@ -353,6 +422,18 @@ export const FolderNode = React.memo(function FolderNode({
{!isRenaming && contextMenuOpen && (
<ContextMenuContent className="w-40">
{isWatched && onRescan && (
<ContextMenuItem onClick={() => onRescan(folder)}>
<RefreshCw className="mr-2 h-4 w-4" />
Re-scan
</ContextMenuItem>
)}
{isWatched && onStopWatching && (
<ContextMenuItem onClick={() => onStopWatching(folder)}>
<EyeOff className="mr-2 h-4 w-4" />
Stop watching
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onCreateSubfolder(folder.id)}>
<FolderPlus className="mr-2 h-4 w-4" />
New subfolder

View file

@ -1,7 +1,7 @@
"use client";
import { useAtom } from "jotai";
import { CirclePlus } from "lucide-react";
import { Search } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
@ -32,6 +32,7 @@ interface FolderTreeViewProps {
onDeleteDocument: (doc: DocumentNodeDoc) => void;
onMoveDocument: (doc: DocumentNodeDoc) => void;
onExportDocument?: (doc: DocumentNodeDoc, format: string) => void;
onVersionHistory?: (doc: DocumentNodeDoc) => void;
activeTypes: DocumentTypeEnum[];
searchQuery?: string;
onDropIntoFolder?: (
@ -40,6 +41,9 @@ interface FolderTreeViewProps {
targetFolderId: number | null
) => void;
onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void;
watchedFolderIds?: Set<number>;
onRescanFolder?: (folder: FolderDisplay) => void;
onStopWatchingFolder?: (folder: FolderDisplay) => void;
}
function groupBy<T>(items: T[], keyFn: (item: T) => string | number): Record<string | number, T[]> {
@ -69,10 +73,14 @@ export function FolderTreeView({
onDeleteDocument,
onMoveDocument,
onExportDocument,
onVersionHistory,
activeTypes,
searchQuery,
onDropIntoFolder,
onReorderFolder,
watchedFolderIds,
onRescanFolder,
onStopWatchingFolder,
}: FolderTreeViewProps) {
const foldersByParent = useMemo(() => groupBy(folders, (f) => f.parentId ?? "root"), [folders]);
@ -158,6 +166,35 @@ export function FolderTreeView({
return states;
}, [folders, docsByFolder, foldersByParent, mentionedDocIds]);
const folderProcessingStates = useMemo(() => {
const states: Record<number, "idle" | "processing" | "failed"> = {};
function compute(folderId: number): { hasProcessing: boolean; hasFailed: boolean } {
const directDocs = docsByFolder[folderId] ?? [];
let hasProcessing = directDocs.some(
(d) => d.status?.state === "pending" || d.status?.state === "processing"
);
let hasFailed = directDocs.some((d) => d.status?.state === "failed");
for (const child of foldersByParent[folderId] ?? []) {
const sub = compute(child.id);
hasProcessing = hasProcessing || sub.hasProcessing;
hasFailed = hasFailed || sub.hasFailed;
}
if (hasProcessing) states[folderId] = "processing";
else if (hasFailed) states[folderId] = "failed";
else states[folderId] = "idle";
return { hasProcessing, hasFailed };
}
for (const f of folders) {
if (states[f.id] === undefined) compute(f.id);
}
return states;
}, [folders, docsByFolder, foldersByParent]);
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
const key = parentId ?? "root";
const childFolders = (foldersByParent[key] ?? [])
@ -191,6 +228,7 @@ export function FolderTreeView({
isRenaming={renamingFolderId === f.id}
childCount={folderChildCounts[f.id] ?? 0}
selectionState={folderSelectionStates[f.id] ?? "none"}
processingState={folderProcessingStates[f.id] ?? "idle"}
onToggleSelect={onToggleFolderSelect}
onToggleExpand={onToggleExpand}
onRename={onRenameFolder}
@ -204,6 +242,9 @@ export function FolderTreeView({
siblingPositions={siblingPositions}
contextMenuOpen={openContextMenuId === `folder-${f.id}`}
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `folder-${f.id}` : null)}
isWatched={watchedFolderIds?.has(f.id)}
onRescan={onRescanFolder}
onStopWatching={onStopWatchingFolder}
/>
);
@ -225,6 +266,7 @@ export function FolderTreeView({
onDelete={onDeleteDocument}
onMove={onMoveDocument}
onExport={onExportDocument}
onVersionHistory={onVersionHistory}
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
/>
@ -250,8 +292,9 @@ export function FolderTreeView({
if (treeNodes.length === 0 && (activeTypes.length > 0 || searchQuery)) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
<CirclePlus className="h-10 w-10 rotate-45" />
<p className="text-sm">No matching documents</p>
<Search className="h-10 w-10" />
<p className="text-sm text-muted-foreground">No matching documents</p>
<p className="text-xs text-muted-foreground/70 mt-1">Try a different search term</p>
</div>
);
}

View file

@ -0,0 +1,258 @@
"use client";
import { Check, ChevronRight, Clock, Copy, RotateCcw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cn } from "@/lib/utils";
interface DocumentVersionSummary {
version_number: number;
title: string;
content_hash: string;
created_at: string | null;
}
interface VersionHistoryProps {
documentId: number;
documentType: string;
}
const VERSION_DOCUMENT_TYPES = new Set(["LOCAL_FOLDER_FILE", "OBSIDIAN_CONNECTOR"]);
export function isVersionableType(documentType: string) {
return VERSION_DOCUMENT_TYPES.has(documentType);
}
const DIALOG_CLASSES =
"select-none max-w-[900px] w-[95vw] md:w-[90vw] h-[90vh] md:h-[80vh] max-h-[640px] flex flex-col md:flex-row p-0 gap-0 overflow-hidden [--card:var(--background)] dark:[--card:oklch(0.205_0_0)] dark:[--background:oklch(0.205_0_0)]";
export function VersionHistoryButton({ documentId, documentType }: VersionHistoryProps) {
if (!isVersionableType(documentType)) return null;
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1.5 text-xs">
<Clock className="h-3.5 w-3.5" />
Versions
</Button>
</DialogTrigger>
<DialogContent className={DIALOG_CLASSES}>
<DialogTitle className="sr-only">Version History</DialogTitle>
<VersionHistoryPanel documentId={documentId} />
</DialogContent>
</Dialog>
);
}
export function VersionHistoryDialog({
open,
onOpenChange,
documentId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
documentId: number;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={DIALOG_CLASSES}>
<DialogTitle className="sr-only">Version History</DialogTitle>
{open && <VersionHistoryPanel documentId={documentId} />}
</DialogContent>
</Dialog>
);
}
function formatRelativeTime(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60_000);
if (diffMin < 1) return "Just now";
if (diffMin < 60) return `${diffMin} minute${diffMin !== 1 ? "s" : ""} ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr} hour${diffHr !== 1 ? "s" : ""} ago`;
return new Date(dateStr).toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function VersionHistoryPanel({ documentId }: { documentId: number }) {
const [versions, setVersions] = useState<DocumentVersionSummary[]>([]);
const [loading, setLoading] = useState(true);
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
const [versionContent, setVersionContent] = useState<string>("");
const [contentLoading, setContentLoading] = useState(false);
const [restoring, setRestoring] = useState(false);
const [copied, setCopied] = useState(false);
const loadVersions = useCallback(async () => {
setLoading(true);
try {
const data = await documentsApiService.listDocumentVersions(documentId);
setVersions(data as DocumentVersionSummary[]);
} catch {
toast.error("Failed to load version history");
} finally {
setLoading(false);
}
}, [documentId]);
useEffect(() => {
loadVersions();
}, [loadVersions]);
const handleSelectVersion = async (versionNumber: number) => {
if (selectedVersion === versionNumber) return;
setSelectedVersion(versionNumber);
setContentLoading(true);
try {
const data = (await documentsApiService.getDocumentVersion(documentId, versionNumber)) as {
source_markdown: string;
};
setVersionContent(data.source_markdown || "");
} catch {
toast.error("Failed to load version content");
} finally {
setContentLoading(false);
}
};
const handleRestore = async (versionNumber: number) => {
setRestoring(true);
try {
await documentsApiService.restoreDocumentVersion(documentId, versionNumber);
toast.success(`Restored version ${versionNumber}`);
await loadVersions();
} catch {
toast.error("Failed to restore version");
} finally {
setRestoring(false);
}
};
const handleCopy = () => {
navigator.clipboard.writeText(versionContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (loading) {
return (
<div className="flex flex-1 items-center justify-center">
<Spinner size="lg" className="text-muted-foreground" />
</div>
);
}
if (versions.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
<p className="text-sm">No version history available yet</p>
<p className="text-xs mt-1">Versions are created when file content changes</p>
</div>
);
}
const selectedVersionData = versions.find((v) => v.version_number === selectedVersion);
return (
<>
{/* Left panel — version list */}
<nav className="w-full md:w-[260px] shrink-0 flex flex-col border-b md:border-b-0 md:border-r border-border">
<div className="px-4 pr-12 md:pr-4 pt-5 pb-2">
<h2 className="text-sm font-semibold text-foreground">Version History</h2>
</div>
<div className="flex-1 overflow-y-auto p-2">
<div className="flex flex-col gap-0.5">
{versions.map((v) => (
<button
key={v.version_number}
type="button"
onClick={() => handleSelectVersion(v.version_number)}
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:outline-none w-full",
selectedVersion === v.version_number
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)}
>
<div className="flex-1 min-w-0 space-y-0.5">
<p className="text-sm font-medium truncate">
{v.created_at
? formatRelativeTime(v.created_at)
: `Version ${v.version_number}`}
</p>
{v.title && <p className="text-xs text-muted-foreground truncate">{v.title}</p>}
</div>
<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-50" />
</button>
))}
</div>
</div>
</nav>
{/* Right panel — content preview */}
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
{selectedVersion !== null && selectedVersionData ? (
<>
<div className="flex items-center justify-between pl-6 pr-14 pt-5 pb-2">
<h2 className="text-sm font-semibold truncate">
{selectedVersionData.title || `Version ${selectedVersion}`}
</h2>
<div className="flex items-center gap-1.5 shrink-0">
<Button
variant="outline"
size="sm"
className="gap-1.5 text-xs"
onClick={handleCopy}
disabled={contentLoading || copied}
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? "Copied" : "Copy"}
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5 text-xs"
disabled={restoring || contentLoading}
onClick={() => handleRestore(selectedVersion)}
>
{restoring ? <Spinner size="xs" /> : <RotateCcw className="h-3 w-3" />}
Restore
</Button>
</div>
</div>
<Separator />
<div className="flex-1 overflow-y-auto px-6 py-4">
{contentLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="sm" className="text-muted-foreground" />
</div>
) : (
<pre className="text-sm whitespace-pre-wrap font-mono leading-relaxed text-foreground/90">
{versionContent || "(empty)"}
</pre>
)}
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground">
<p className="text-sm">Select a version to preview</p>
</div>
)}
</div>
</>
);
}

View file

@ -1,12 +1,14 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { AlertCircle, XIcon } from "lucide-react";
import { Download, FileQuestionMark, FileText, Loader2, RefreshCw, XIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { VersionHistoryButton } from "@/components/documents/version-history";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Skeleton } from "@/components/ui/skeleton";
@ -18,11 +20,16 @@ const PlateEditor = dynamic(
{ ssr: false, loading: () => <Skeleton className="h-64 w-full" /> }
);
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
interface EditorContent {
document_id: number;
title: string;
document_type?: string;
source_markdown: string;
content_size_bytes?: number;
chunk_count?: number;
truncated?: boolean;
}
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
@ -62,6 +69,7 @@ export function EditorPanelContent({
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [downloading, setDownloading] = useState(false);
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
const markdownRef = useRef<string>("");
@ -69,8 +77,10 @@ export function EditorPanelContent({
const changeCountRef = useRef(0);
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
setIsLoading(true);
setError(null);
setEditorDoc(null);
@ -78,7 +88,7 @@ export function EditorPanelContent({
initialLoadDone.current = false;
changeCountRef.current = 0;
const fetchContent = async () => {
const doFetch = async () => {
const token = getBearerToken();
if (!token) {
redirectToLogin();
@ -88,10 +98,15 @@ export function EditorPanelContent({
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
{ method: "GET" }
{ method: "GET", signal: controller.signal }
const url = new URL(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
);
url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD));
if (cancelled) return;
const response = await authenticatedFetch(url.toString(), { method: "GET" });
if (controller.signal.aborted) return;
if (!response.ok) {
const errorData = await response
@ -115,18 +130,16 @@ export function EditorPanelContent({
setEditorDoc(data);
initialLoadDone.current = true;
} catch (err) {
if (cancelled) return;
if (controller.signal.aborted) return;
console.error("Error fetching document:", err);
setError(err instanceof Error ? err.message : "Failed to fetch document");
} finally {
if (!cancelled) setIsLoading(false);
if (!controller.signal.aborted) setIsLoading(false);
}
};
fetchContent();
return () => {
cancelled = true;
};
doFetch().catch(() => {});
return () => controller.abort();
}, [documentId, searchSpaceId, title]);
const handleMarkdownChange = useCallback((md: string) => {
@ -175,7 +188,7 @@ export function EditorPanelContent({
}, [documentId, searchSpaceId]);
const isEditableType = editorDoc
? EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")
? EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "") && !isLargeDocument
: false;
return (
@ -187,12 +200,17 @@ export function EditorPanelContent({
<p className="text-[10px] text-muted-foreground">Unsaved changes</p>
)}
</div>
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close editor panel</span>
</Button>
)}
<div className="flex items-center gap-1 shrink-0">
{editorDoc?.document_type && (
<VersionHistoryButton documentId={documentId} documentType={editorDoc.document_type} />
)}
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close editor panel</span>
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-hidden">
@ -200,12 +218,79 @@ export function EditorPanelContent({
<EditorPanelSkeleton />
) : error || !editorDoc ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
<AlertCircle className="size-8 text-destructive" />
<div>
<p className="font-medium text-foreground">Failed to load document</p>
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
{error?.toLowerCase().includes("still being processed") ? (
<div className="rounded-full bg-muted/50 p-3">
<RefreshCw className="size-6 text-muted-foreground animate-spin" />
</div>
) : (
<div className="rounded-full bg-muted/50 p-3">
<FileQuestionMark className="size-6 text-muted-foreground" />
</div>
)}
<div className="space-y-1 max-w-xs">
<p className="font-medium text-foreground">
{error?.toLowerCase().includes("still being processed")
? "Document is processing"
: "Document unavailable"}
</p>
<p className="text-sm text-muted-foreground">
{error || "An unknown error occurred"}
</p>
</div>
</div>
) : isLargeDocument ? (
<div className="h-full overflow-y-auto px-5 py-4">
<Alert className="mb-4">
<FileText className="size-4" />
<AlertDescription className="flex items-center justify-between gap-4">
<span>
This document is too large for the editor (
{Math.round((editorDoc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
{editorDoc.chunk_count ?? 0} chunks). Showing a preview below.
</span>
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1.5"
disabled={downloading}
onClick={async () => {
setDownloading(true);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
{ method: "GET" }
);
if (!response.ok) throw new Error("Download failed");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const disposition = response.headers.get("content-disposition");
const match = disposition?.match(/filename="(.+)"/);
a.download = match?.[1] ?? `${editorDoc.title || "document"}.md`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success("Download started");
} catch {
toast.error("Failed to download document");
} finally {
setDownloading(false);
}
}}
>
{downloading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Download className="size-3.5" />
)}
{downloading ? "Preparing..." : "Download .md"}
</Button>
</AlertDescription>
</Alert>
<MarkdownViewer content={editorDoc.source_markdown} />
</div>
) : isEditableType ? (
<PlateEditor
key={documentId}

View file

@ -1,4 +1,5 @@
"use client";
import Image from 'next/image';
import { AnimatePresence, motion } from "motion/react";
import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay";
@ -81,6 +82,15 @@ function UseCaseCard({
alt={title}
className="w-full rounded-xl object-cover transition-transform duration-500 group-hover:scale-[1.02]"
/>
<div className="relative w-full h-48">
<Image
src={src}
alt={title}
fill
className="rounded-xl object-cover transition-transform duration-500 group-hover:scale-[1.02]"
unoptimized={src.endsWith('.gif')}
/>
</div>
</div>
<div className="px-5 py-4">
<h3 className="text-base font-semibold text-neutral-900 dark:text-white">{title}</h3>

View file

@ -775,7 +775,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
<AlertDialogHeader>
<AlertDialogTitle>{t("delete_chat")}</AlertDialogTitle>
<AlertDialogDescription>
{t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "}
{t("delete_chat_confirm")}{" "}
<span className="font-medium break-all">{chatToDelete?.name}</span>?{" "}
{t("action_cannot_undone")}
</AlertDialogDescription>
</AlertDialogHeader>
@ -835,9 +836,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
<span className={isRenamingChat ? "opacity-0" : ""}>
{tSidebar("rename") || "Rename"}
</span>
{isRenamingChat && (
<span className="absolute h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
)}
{isRenamingChat && <Spinner size="sm" className="absolute" />}
</Button>
</DialogFooter>
</DialogContent>
@ -865,9 +864,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<span className={isDeletingSearchSpace ? "opacity-0" : ""}>{tCommon("delete")}</span>
{isDeletingSearchSpace && (
<span className="absolute h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
)}
{isDeletingSearchSpace && <Spinner size="sm" className="absolute" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -895,9 +892,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<span className={isLeavingSearchSpace ? "opacity-0" : ""}>{t("leave")}</span>
{isLeavingSearchSpace && (
<span className="absolute h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
)}
{isLeavingSearchSpace && <Spinner size="sm" className="absolute" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -19,7 +19,7 @@ const EditorPanelContent = dynamic(
import("@/components/editor-panel/editor-panel").then((m) => ({
default: m.EditorPanelContent,
})),
{ ssr: false, loading: () => <Skeleton className="h-96 w-full" /> }
{ ssr: false, loading: () => null }
);
const HitlEditPanelContent = dynamic(

View file

@ -109,6 +109,7 @@ export function AllPrivateChatsSidebarContent({
queryKey: ["all-threads", searchSpaceId],
queryFn: () => fetchThreads(Number(searchSpaceId)),
enabled: !!searchSpaceId && !isSearchMode,
placeholderData: () => queryClient.getQueryData(["threads", searchSpaceId, { limit: 40 }]),
});
const {
@ -349,7 +350,7 @@ export function AllPrivateChatsSidebarContent({
<div
key={thread.id}
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"sidebar-item-lazy 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",

View file

@ -349,7 +349,7 @@ export function AllSharedChatsSidebarContent({
<div
key={thread.id}
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"sidebar-item-lazy 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",

View file

@ -21,6 +21,7 @@ import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
import type { FolderDisplay } from "@/components/documents/FolderNode";
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
import { FolderTreeView } from "@/components/documents/FolderTreeView";
import { VersionHistoryDialog } from "@/components/documents/version-history";
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import {
AlertDialog,
@ -40,6 +41,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useMediaQuery } from "@/hooks/use-media-query";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { queries } from "@/zero/queries/index";
@ -92,6 +94,50 @@ export function DocumentsSidebar({
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
useEffect(() => {
const api = typeof window !== "undefined" ? window.electronAPI : null;
if (!api?.getWatchedFolders) return;
async function loadWatchedIds() {
const folders = await api!.getWatchedFolders();
if (folders.length === 0) {
try {
const backendFolders = await documentsApiService.getWatchedFolders(searchSpaceId);
for (const bf of backendFolders) {
const meta = bf.metadata as Record<string, unknown> | null;
if (!meta?.watched || !meta.folder_path) continue;
await api!.addWatchedFolder({
path: meta.folder_path as string,
name: bf.name,
rootFolderId: bf.id,
searchSpaceId: bf.search_space_id,
excludePatterns: (meta.exclude_patterns as string[]) ?? [],
fileExtensions: (meta.file_extensions as string[] | null) ?? null,
active: true,
});
}
const recovered = await api!.getWatchedFolders();
const ids = new Set(
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
);
setWatchedFolderIds(ids);
return;
} catch (err) {
console.error("[DocumentsSidebar] Recovery from backend failed:", err);
}
}
const ids = new Set(
folders.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
);
setWatchedFolderIds(ids);
}
loadWatchedIds();
}, [searchSpaceId]);
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
@ -134,7 +180,12 @@ export function DocumentsSidebar({
const treeDocuments: DocumentNodeDoc[] = useMemo(() => {
const zeroDocs = (zeroAllDocs ?? [])
.filter((d) => d.title && d.title.trim() !== "")
.filter((d) => {
if (!d.title || d.title.trim() === "") return false;
const state = (d.status as { state?: string } | undefined)?.state;
if (state === "deleting") return false;
return true;
})
.map((d) => ({
id: d.id,
title: d.title,
@ -223,6 +274,53 @@ export function DocumentsSidebar({
[createFolderParentId, searchSpaceId, setExpandedFolderMap]
);
const handleRescanFolder = useCallback(
async (folder: FolderDisplay) => {
const api = window.electronAPI;
if (!api) return;
const watchedFolders = await api.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
if (!matched) {
toast.error("This folder is not being watched");
return;
}
try {
await documentsApiService.folderIndex(searchSpaceId, {
folder_path: matched.path,
folder_name: matched.name,
search_space_id: searchSpaceId,
root_folder_id: folder.id,
});
toast.success(`Re-scanning folder: ${matched.name}`);
} catch (err) {
toast.error((err as Error)?.message || "Failed to re-scan folder");
}
},
[searchSpaceId]
);
const handleStopWatching = useCallback(async (folder: FolderDisplay) => {
const api = window.electronAPI;
if (!api) return;
const watchedFolders = await api.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
if (!matched) {
toast.error("This folder is not being watched");
return;
}
await api.removeWatchedFolder(matched.path);
try {
await foldersApiService.stopWatching(folder.id);
} catch (err) {
console.error("[DocumentsSidebar] Failed to clear watched metadata:", err);
}
toast.success(`Stopped watching: ${matched.name}`);
}, []);
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
try {
await foldersApiService.updateFolder(folder.id, { name: newName });
@ -235,6 +333,14 @@ export function DocumentsSidebar({
const handleDeleteFolder = useCallback(async (folder: FolderDisplay) => {
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
try {
const api = window.electronAPI;
if (api) {
const watchedFolders = await api.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
if (matched) {
await api.removeWatchedFolder(matched.path);
}
}
await foldersApiService.deleteFolder(folder.id);
toast.success("Folder deleted");
} catch (e: unknown) {
@ -448,6 +554,7 @@ export function DocumentsSidebar({
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [versionDocId, setVersionDocId] = useState<number | null>(null);
const handleBulkDeleteSelected = useCallback(async () => {
if (deletableSelectedIds.length === 0) return;
@ -651,56 +758,72 @@ export function DocumentsSidebar({
/>
</div>
{deletableSelectedIds.length > 0 && (
<div className="shrink-0 flex items-center justify-center px-4 py-1.5 animate-in fade-in duration-150">
<button
type="button"
onClick={() => setBulkDeleteConfirmOpen(true)}
className="flex items-center gap-1.5 px-3 py-1 rounded-md bg-destructive text-destructive-foreground shadow-sm text-xs font-medium hover:bg-destructive/90 transition-colors"
>
<Trash2 size={12} />
Delete {deletableSelectedIds.length}{" "}
{deletableSelectedIds.length === 1 ? "item" : "items"}
</button>
</div>
)}
<div className="relative flex-1 min-h-0 overflow-auto">
{deletableSelectedIds.length > 0 && (
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-center px-4 py-1.5 animate-in fade-in duration-150 pointer-events-none">
<button
type="button"
onClick={() => setBulkDeleteConfirmOpen(true)}
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1 rounded-md bg-destructive text-destructive-foreground shadow-lg text-xs font-medium hover:bg-destructive/90 transition-colors"
>
<Trash2 size={12} />
Delete {deletableSelectedIds.length}{" "}
{deletableSelectedIds.length === 1 ? "item" : "items"}
</button>
</div>
)}
<FolderTreeView
folders={treeFolders}
documents={searchFilteredDocuments}
expandedIds={expandedIds}
onToggleExpand={toggleFolderExpand}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
onToggleFolderSelect={handleToggleFolderSelect}
onRenameFolder={handleRenameFolder}
onDeleteFolder={handleDeleteFolder}
onMoveFolder={handleMoveFolder}
onCreateFolder={handleCreateFolder}
searchQuery={debouncedSearch.trim() || undefined}
onPreviewDocument={(doc) => {
openEditorPanel({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onEditDocument={(doc) => {
openEditorPanel({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument}
onExportDocument={handleExportDocument}
activeTypes={activeTypes}
onDropIntoFolder={handleDropIntoFolder}
onReorderFolder={handleReorderFolder}
/>
<FolderTreeView
folders={treeFolders}
documents={searchFilteredDocuments}
expandedIds={expandedIds}
onToggleExpand={toggleFolderExpand}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
onToggleFolderSelect={handleToggleFolderSelect}
onRenameFolder={handleRenameFolder}
onDeleteFolder={handleDeleteFolder}
onMoveFolder={handleMoveFolder}
onCreateFolder={handleCreateFolder}
searchQuery={debouncedSearch.trim() || undefined}
onPreviewDocument={(doc) => {
openEditorPanel({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onEditDocument={(doc) => {
openEditorPanel({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument}
onExportDocument={handleExportDocument}
onVersionHistory={(doc) => setVersionDocId(doc.id)}
activeTypes={activeTypes}
onDropIntoFolder={handleDropIntoFolder}
onReorderFolder={handleReorderFolder}
watchedFolderIds={watchedFolderIds}
onRescanFolder={handleRescanFolder}
onStopWatchingFolder={handleStopWatching}
/>
</div>
</div>
{versionDocId !== null && (
<VersionHistoryDialog
open
onOpenChange={(open) => {
if (!open) setVersionDocId(null);
}}
documentId={versionDocId}
/>
)}
<FolderPickerDialog
open={folderPickerOpen}
onOpenChange={setFolderPickerOpen}

View file

@ -20,7 +20,7 @@ import {
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
@ -178,12 +178,23 @@ export function InboxSidebarContent({
const [mounted, setMounted] = useState(false);
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
const connectorRafRef = useRef<number>();
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
if (connectorRafRef.current) return;
connectorRafRef.current = requestAnimationFrame(() => {
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
connectorRafRef.current = undefined;
});
}, []);
useEffect(
() => () => {
if (connectorRafRef.current) cancelAnimationFrame(connectorRafRef.current);
},
[]
);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
@ -289,15 +300,14 @@ export function InboxSidebarContent({
[activeFilter]
);
// Defer non-urgent list updates so the search input stays responsive.
// The deferred snapshot lags one render behind the live value intentionally.
const deferredTabItems = useDeferredValue(activeSource.items);
const deferredSearchItems = useDeferredValue(searchResponse?.items ?? []);
// Two data paths: search mode (API) or default (per-tab data source)
const filteredItems = useMemo(() => {
let tabItems: InboxItem[];
if (isSearchMode) {
tabItems = searchResponse?.items ?? [];
} else {
tabItems = activeSource.items;
}
const tabItems: InboxItem[] = isSearchMode ? deferredSearchItems : deferredTabItems;
let result = tabItems;
if (activeFilter !== "all") {
@ -310,8 +320,8 @@ export function InboxSidebarContent({
return result;
}, [
isSearchMode,
searchResponse,
activeSource.items,
deferredSearchItems,
deferredTabItems,
activeTab,
activeFilter,
selectedSource,
@ -920,6 +930,7 @@ export function InboxSidebarContent({
"transition-colors cursor-pointer",
isMarkingAsRead && "opacity-50 pointer-events-none"
)}
style={{ contentVisibility: "auto", containIntrinsicSize: "0 80px" }}
>
{isMobile ? (
<button

View file

@ -1,18 +1,24 @@
"use client";
import { AlertCircle, Pencil } from "lucide-react";
import { Download, FileQuestionMark, FileText, Loader2, PenLine, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { PlateEditor } from "@/components/editor/plate-editor";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
interface DocumentContent {
document_id: number;
title: string;
document_type?: string;
source_markdown: string;
content_size_bytes?: number;
chunk_count?: number;
truncated?: boolean;
}
function DocumentSkeleton() {
@ -49,13 +55,16 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
const [error, setError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [downloading, setDownloading] = useState(false);
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
const markdownRef = useRef<string>("");
const initialLoadDone = useRef(false);
const changeCountRef = useRef(0);
const isLargeDocument = (doc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
setIsLoading(true);
setError(null);
setDoc(null);
@ -64,7 +73,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
initialLoadDone.current = false;
changeCountRef.current = 0;
const fetchContent = async () => {
const doFetch = async () => {
const token = getBearerToken();
if (!token) {
redirectToLogin();
@ -74,10 +83,15 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
{ method: "GET" }
{ method: "GET", signal: controller.signal }
const url = new URL(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
);
url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD));
if (cancelled) return;
const response = await authenticatedFetch(url.toString(), { method: "GET" });
if (controller.signal.aborted) return;
if (!response.ok) {
const errorData = await response
@ -98,18 +112,16 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
setDoc(data);
initialLoadDone.current = true;
} catch (err) {
if (cancelled) return;
if (controller.signal.aborted) return;
console.error("Error fetching document:", err);
setError(err instanceof Error ? err.message : "Failed to fetch document");
} finally {
if (!cancelled) setIsLoading(false);
if (!controller.signal.aborted) setIsLoading(false);
}
};
fetchContent();
return () => {
cancelled = true;
};
doFetch().catch(() => {});
return () => controller.abort();
}, [documentId, searchSpaceId]);
const handleMarkdownChange = useCallback((md: string) => {
@ -160,22 +172,40 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
if (isLoading) return <DocumentSkeleton />;
if (error || !doc) {
const isProcessing = error?.toLowerCase().includes("still being processed");
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
<AlertCircle className="size-10 text-destructive" />
<div>
<p className="font-medium text-foreground text-lg">Failed to load document</p>
<p className="text-sm text-muted-foreground mt-1">
{error || "An unknown error occurred"}
</p>
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
<div className="rounded-full bg-muted/50 p-4">
{isProcessing ? (
<RefreshCw className="size-8 text-muted-foreground animate-spin" />
) : (
<FileQuestionMark className="size-8 text-muted-foreground" />
)}
</div>
<div className="space-y-1.5 max-w-sm">
<p className="font-semibold text-foreground text-lg">
{isProcessing ? "Document is processing" : "Document unavailable"}
</p>
<p className="text-sm text-muted-foreground">{error || "An unknown error occurred"}</p>
</div>
{!isProcessing && (
<Button
variant="outline"
size="sm"
className="mt-1 gap-1.5"
onClick={() => window.location.reload()}
>
<RefreshCw className="size-3.5" />
Retry
</Button>
)}
</div>
);
}
const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type ?? "");
const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type ?? "") && !isLargeDocument;
if (isEditing) {
if (isEditing && !isLargeDocument) {
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0">
@ -229,14 +259,69 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
onClick={() => setIsEditing(true)}
className="gap-1.5"
>
<Pencil className="size-3.5" />
<PenLine className="size-3.5" />
Edit
</Button>
)}
</div>
<div className="flex-1 overflow-auto">
<div className="max-w-4xl mx-auto px-6 py-6">
<MarkdownViewer content={doc.source_markdown} />
{isLargeDocument ? (
<>
<Alert className="mb-4">
<FileText className="size-4" />
<AlertDescription className="flex items-center justify-between gap-4">
<span>
This document is too large for the editor (
{Math.round((doc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
{doc.chunk_count ?? 0} chunks). Showing a preview below.
</span>
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1.5"
disabled={downloading}
onClick={async () => {
setDownloading(true);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
{ method: "GET" }
);
if (!response.ok) throw new Error("Download failed");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const disposition = response.headers.get("content-disposition");
const match = disposition?.match(/filename="(.+)"/);
a.download = match?.[1] ?? `${doc.title || "document"}.md`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success("Download started");
} catch {
toast.error("Failed to download document");
} finally {
setDownloading(false);
}
}}
>
{downloading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Download className="size-3.5" />
)}
{downloading ? "Preparing..." : "Download .md"}
</Button>
</AlertDescription>
</Alert>
<MarkdownViewer content={doc.source_markdown} />
</>
) : (
<MarkdownViewer content={doc.source_markdown} />
)}
</div>
</div>
</div>

View file

@ -72,7 +72,7 @@ export function TabBar({ onTabSwitch, onNewChat, rightActions, className }: TabB
if (tabs.length <= 1) return null;
return (
<div className={cn("mb-2 flex h-9 items-center shrink-0 px-1 gap-0.5", className)}>
<div className={cn("mb-2 flex h-9 items-center shrink-0 px-1 gap-0.5 select-none", className)}>
<div
ref={scrollRef}
className="flex h-full items-center flex-1 gap-0.5 overflow-x-auto overflow-y-hidden scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-1"

View file

@ -3,6 +3,8 @@ import { createMathPlugin } from "@streamdown/math";
import { Streamdown, type StreamdownProps } from "streamdown";
import "katex/dist/katex.min.css";
import { cn } from "@/lib/utils";
import Image from 'next/image';
import { is } from "drizzle-orm";
const code = createCodePlugin({
themes: ["nord", "nord"],
@ -15,6 +17,7 @@ const math = createMathPlugin({
interface MarkdownViewerProps {
content: string;
className?: string;
maxLength?: number;
}
/**
@ -79,8 +82,10 @@ function convertLatexDelimiters(content: string): string {
return content;
}
export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
const processedContent = convertLatexDelimiters(stripOuterMarkdownFence(content));
export function MarkdownViewer({ content, className, maxLength }: MarkdownViewerProps) {
const isTruncated = maxLength != null && content.length > maxLength;
const displayContent = isTruncated ? content.slice(0, maxLength) : content;
const processedContent = convertLatexDelimiters(stripOuterMarkdownFence(displayContent));
const components: StreamdownProps["components"] = {
p: ({ children, ...props }) => (
<p className="my-2" {...props}>
@ -124,16 +129,31 @@ export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
<blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />
),
hr: ({ ...props }) => <hr className="my-4 border-muted" {...props} />,
img: ({ src, alt, width: _w, height: _h, ...props }) => (
// eslint-disable-next-line @next/next/no-img-element
<img
className="max-w-full h-auto my-4 rounded"
alt={alt || "markdown image"}
src={typeof src === "string" ? src : ""}
loading="lazy"
{...props}
/>
),
img: ({ src, alt, width: _w, height: _h, ...props }) => {
const isDataOrUnknownUrl = typeof src === "string" && (src.startsWith("data:") || !src.startsWith("http"));
return isDataOrUnknownUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
className="max-w-full h-auto my-4 rounded"
alt={alt || "markdown image"}
src={src}
loading="lazy"
{...props}
/>
) : (
<Image
className="max-w-full h-auto my-4 rounded"
alt={alt || "markdown image"}
src={typeof src === "string" ? src : ""}
width={_w || 800}
height={_h || 600}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 60vw"
unoptimized={isDataOrUnknownUrl}
{...props}
/>
);
},
table: ({ ...props }) => (
<div className="overflow-x-auto my-4 rounded-lg border border-border w-full">
<table className="w-full divide-y divide-border" {...props} />
@ -171,6 +191,12 @@ export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
>
{processedContent}
</Streamdown>
{isTruncated && (
<p className="mt-4 text-sm text-muted-foreground italic">
Content truncated ({Math.round(content.length / 1024)}KB total). Showing first{" "}
{Math.round(maxLength / 1024)}KB.
</p>
)}
</div>
);
}

View file

@ -4,6 +4,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
forwardRef,
useCallback,
useDeferredValue,
useEffect,
useImperativeHandle,
useMemo,
@ -81,6 +82,9 @@ export const DocumentMentionPicker = forwardRef<
// Debounced search value to minimize API calls and prevent race conditions
const search = externalSearch;
const debouncedSearch = useDebounced(search, DEBOUNCE_MS);
// Deferred snapshot of debouncedSearch — client-side filtering uses this so it
// is treated as a non-urgent update, keeping the input responsive.
const deferredSearch = useDeferredValue(debouncedSearch);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -245,12 +249,14 @@ export const DocumentMentionPicker = forwardRef<
* Client-side filtering for single character searches.
* Filters cached documents locally for instant feedback without additional API calls.
* Server-side search is reserved for 2+ character queries to leverage database indexing.
* Uses deferredSearch (a deferred snapshot of debouncedSearch) so this memo is treated
* as non-urgent React can interrupt it to keep the input responsive.
*/
const clientFilteredDocs = useMemo(() => {
if (!isSingleCharSearch) return null;
const searchLower = debouncedSearch.trim().toLowerCase();
const searchLower = deferredSearch.trim().toLowerCase();
return accumulatedDocuments.filter((doc) => doc.title.toLowerCase().includes(searchLower));
}, [isSingleCharSearch, debouncedSearch, accumulatedDocuments]);
}, [isSingleCharSearch, deferredSearch, accumulatedDocuments]);
// Select data source based on search length: client-filtered for single char, server results for 2+
const actualDocuments = isSingleCharSearch ? (clientFilteredDocs ?? []) : accumulatedDocuments;

View file

@ -498,7 +498,7 @@ export function ModelSelector({
}}
>
<Plus className="size-4 text-primary" />
<span className="text-sm font-medium">Add LLM Model</span>
<span className="text-sm font-medium">Add Model</span>
</Button>
</div>
</CommandList>

View file

@ -5,6 +5,7 @@ import { Plus, Zap } from "lucide-react";
import {
forwardRef,
useCallback,
useDeferredValue,
useEffect,
useImperativeHandle,
useMemo,
@ -41,15 +42,19 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
const shouldScrollRef = useRef(false);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
// Defer the search value so filtering is non-urgent and the input stays responsive
const deferredSearch = useDeferredValue(externalSearch);
const filtered = useMemo(() => {
const list = prompts ?? [];
if (!externalSearch) return list;
return list.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase()));
}, [prompts, externalSearch]);
if (!deferredSearch) return list;
return list.filter((a) => a.name.toLowerCase().includes(deferredSearch.toLowerCase()));
}, [prompts, deferredSearch]);
const prevSearchRef = useRef(externalSearch);
if (prevSearchRef.current !== externalSearch) {
prevSearchRef.current = externalSearch;
// Reset highlight when the deferred (filtered) search changes
const prevSearchRef = useRef(deferredSearch);
if (prevSearchRef.current !== deferredSearch) {
prevSearchRef.current = deferredSearch;
if (highlightedIndex !== 0) {
setHighlightedIndex(0);
}

View file

@ -1,7 +1,18 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, ChevronDown, ExternalLink, FileText, Hash, Sparkles, X } from "lucide-react";
import {
BookOpen,
ChevronDown,
ChevronUp,
ExternalLink,
FileQuestionMark,
FileText,
Hash,
Loader2,
Sparkles,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useTranslations } from "next-intl";
import type React from "react";
@ -10,7 +21,6 @@ import { createPortal } from "react-dom";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import type {
@ -48,7 +58,8 @@ const formatDocumentType = (type: string) => {
// which break auto-scroll functionality
interface ChunkCardProps {
chunk: { id: number; content: string };
index: number;
localIndex: number;
chunkNumber: number;
totalChunks: number;
isCited: boolean;
isActive: boolean;
@ -56,51 +67,52 @@ interface ChunkCardProps {
}
const ChunkCard = memo(
forwardRef<HTMLDivElement, ChunkCardProps>(({ chunk, index, totalChunks, isCited }, ref) => {
return (
<div
ref={ref}
data-chunk-index={index}
className={cn(
"group relative rounded-2xl border-2 transition-all duration-300",
isCited
? "bg-linear-to-br from-primary/5 via-primary/10 to-primary/5 border-primary shadow-lg shadow-primary/10"
: "bg-card border-border/50 hover:border-border hover:shadow-md"
)}
>
{/* Cited indicator glow effect */}
{isCited && <div className="absolute inset-0 rounded-2xl bg-primary/5 blur-xl -z-10" />}
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50">
<div className="flex items-center gap-3">
<div
className={cn(
"flex items-center justify-center w-8 h-8 rounded-full text-sm font-semibold transition-colors",
isCited
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground group-hover:bg-muted/80"
)}
>
{index + 1}
</div>
<span className="text-sm text-muted-foreground">of {totalChunks} chunks</span>
</div>
{isCited && (
<Badge variant="default" className="gap-1.5 px-3 py-1">
<Sparkles className="h-3 w-3" />
Cited Source
</Badge>
forwardRef<HTMLDivElement, ChunkCardProps>(
({ chunk, localIndex, chunkNumber, totalChunks, isCited }, ref) => {
return (
<div
ref={ref}
data-chunk-index={localIndex}
className={cn(
"group relative rounded-2xl border-2 transition-all duration-300",
isCited
? "bg-linear-to-br from-primary/5 via-primary/10 to-primary/5 border-primary shadow-lg shadow-primary/10"
: "bg-card border-border/50 hover:border-border hover:shadow-md"
)}
</div>
>
{isCited && <div className="absolute inset-0 rounded-2xl bg-primary/5 blur-xl -z-10" />}
{/* Content */}
<div className="p-5 overflow-hidden">
<MarkdownViewer content={chunk.content} />
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50">
<div className="flex items-center gap-3">
<div
className={cn(
"flex items-center justify-center w-8 h-8 rounded-full text-sm font-semibold transition-colors",
isCited
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground group-hover:bg-muted/80"
)}
>
{chunkNumber}
</div>
<span className="text-sm text-muted-foreground">
Chunk {chunkNumber} of {totalChunks}
</span>
</div>
{isCited && (
<Badge variant="default" className="gap-1.5 px-3 py-1">
<Sparkles className="h-3 w-3" />
Cited Source
</Badge>
)}
</div>
<div className="p-5 overflow-hidden">
<MarkdownViewer content={chunk.content} maxLength={100_000} />
</div>
</div>
</div>
);
})
);
}
)
);
ChunkCard.displayName = "ChunkCard";
@ -118,7 +130,6 @@ export function SourceDetailPanel({
const t = useTranslations("dashboard");
const scrollAreaRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
const [summaryOpen, setSummaryOpen] = useState(false);
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
const [mounted, setMounted] = useState(false);
const [_hasScrolledToCited, setHasScrolledToCited] = useState(false);
@ -140,20 +151,93 @@ export function SourceDetailPanel({
if (isDocsChunk) {
return documentsApiService.getSurfsenseDocByChunk(chunkId);
}
return documentsApiService.getDocumentByChunk({ chunk_id: chunkId });
return documentsApiService.getDocumentByChunk({ chunk_id: chunkId, chunk_window: 5 });
},
enabled: !!chunkId && open,
staleTime: 5 * 60 * 1000,
});
const totalChunks =
documentData && "total_chunks" in documentData
? (documentData.total_chunks ?? documentData.chunks.length)
: (documentData?.chunks?.length ?? 0);
const [beforeChunks, setBeforeChunks] = useState<
Array<{ id: number; content: string; created_at: string }>
>([]);
const [afterChunks, setAfterChunks] = useState<
Array<{ id: number; content: string; created_at: string }>
>([]);
const [loadingBefore, setLoadingBefore] = useState(false);
const [loadingAfter, setLoadingAfter] = useState(false);
useEffect(() => {
setBeforeChunks([]);
setAfterChunks([]);
}, [chunkId, open]);
const chunkStartIndex =
documentData && "chunk_start_index" in documentData ? (documentData.chunk_start_index ?? 0) : 0;
const initialChunks = documentData?.chunks ?? [];
const allChunks = [...beforeChunks, ...initialChunks, ...afterChunks];
const absoluteStart = chunkStartIndex - beforeChunks.length;
const absoluteEnd = chunkStartIndex + initialChunks.length + afterChunks.length;
const canLoadBefore = absoluteStart > 0;
const canLoadAfter = absoluteEnd < totalChunks;
const EXPAND_SIZE = 10;
const loadBefore = useCallback(async () => {
if (!documentData || !("search_space_id" in documentData) || !canLoadBefore) return;
setLoadingBefore(true);
try {
const count = Math.min(EXPAND_SIZE, absoluteStart);
const result = await documentsApiService.getDocumentChunks({
document_id: documentData.id,
page: 0,
page_size: count,
start_offset: absoluteStart - count,
});
const existingIds = new Set(allChunks.map((c) => c.id));
const newChunks = result.items
.filter((c) => !existingIds.has(c.id))
.map((c) => ({ id: c.id, content: c.content, created_at: c.created_at }));
setBeforeChunks((prev) => [...newChunks, ...prev]);
} catch (err) {
console.error("Failed to load earlier chunks:", err);
} finally {
setLoadingBefore(false);
}
}, [documentData, absoluteStart, canLoadBefore, allChunks]);
const loadAfter = useCallback(async () => {
if (!documentData || !("search_space_id" in documentData) || !canLoadAfter) return;
setLoadingAfter(true);
try {
const result = await documentsApiService.getDocumentChunks({
document_id: documentData.id,
page: 0,
page_size: EXPAND_SIZE,
start_offset: absoluteEnd,
});
const existingIds = new Set(allChunks.map((c) => c.id));
const newChunks = result.items
.filter((c) => !existingIds.has(c.id))
.map((c) => ({ id: c.id, content: c.content, created_at: c.created_at }));
setAfterChunks((prev) => [...prev, ...newChunks]);
} catch (err) {
console.error("Failed to load later chunks:", err);
} finally {
setLoadingAfter(false);
}
}, [documentData, absoluteEnd, canLoadAfter, allChunks]);
const isDirectRenderSource =
sourceType === "TAVILY_API" ||
sourceType === "LINKUP_API" ||
sourceType === "SEARXNG_API" ||
sourceType === "BAIDU_SEARCH_API";
// Find cited chunk index
const citedChunkIndex = documentData?.chunks?.findIndex((chunk) => chunk.id === chunkId) ?? -1;
const citedChunkIndex = allChunks.findIndex((chunk) => chunk.id === chunkId);
// Simple scroll function that scrolls to a chunk by index
const scrollToChunkByIndex = useCallback(
@ -336,10 +420,10 @@ export function SourceDetailPanel({
{documentData && "document_type" in documentData
? formatDocumentType(documentData.document_type)
: sourceType && formatDocumentType(sourceType)}
{documentData?.chunks && (
{totalChunks > 0 && (
<span className="ml-2">
{documentData.chunks.length} chunk
{documentData.chunks.length !== 1 ? "s" : ""}
{totalChunks} chunk{totalChunks !== 1 ? "s" : ""}
{allChunks.length < totalChunks && ` (showing ${allChunks.length})`}
</span>
)}
</p>
@ -392,13 +476,11 @@ export function SourceDetailPanel({
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4 text-center px-6"
>
<div className="w-20 h-20 rounded-full bg-destructive/10 flex items-center justify-center">
<X className="h-10 w-10 text-destructive" />
<div className="w-20 h-20 rounded-full bg-muted/50 flex items-center justify-center">
<FileQuestionMark className="h-10 w-10 text-muted-foreground" />
</div>
<div>
<p className="font-semibold text-destructive text-lg">
Failed to load document
</p>
<p className="font-semibold text-foreground text-lg">Document unavailable</p>
<p className="text-sm text-muted-foreground mt-2 max-w-md">
{documentByChunkFetchingError.message ||
"An unexpected error occurred. Please try again."}
@ -450,7 +532,7 @@ export function SourceDetailPanel({
{!isDirectRenderSource && documentData && (
<div className="flex-1 flex overflow-hidden">
{/* Chunk Navigation Sidebar */}
{documentData.chunks.length > 1 && (
{allChunks.length > 1 && (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
@ -459,7 +541,8 @@ export function SourceDetailPanel({
>
<ScrollArea className="flex-1 h-full">
<div className="p-2 pt-3 flex flex-col gap-1.5">
{documentData.chunks.map((chunk, idx) => {
{allChunks.map((chunk, idx) => {
const absNum = absoluteStart + idx + 1;
const isCited = chunk.id === chunkId;
const isActive = activeChunkIndex === idx;
return (
@ -478,9 +561,9 @@ export function SourceDetailPanel({
? "bg-muted text-foreground"
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground"
)}
title={isCited ? `Chunk ${idx + 1} (Cited)` : `Chunk ${idx + 1}`}
title={isCited ? `Chunk ${absNum} (Cited)` : `Chunk ${absNum}`}
>
{idx + 1}
{absNum}
{isCited && (
<span className="absolute -top-1.5 -right-1.5 flex items-center justify-center w-4 h-4 bg-primary rounded-full border-2 border-background shadow-sm">
<Sparkles className="h-2.5 w-2.5 text-primary-foreground" />
@ -524,44 +607,11 @@ export function SourceDetailPanel({
</motion.div>
)}
{/* Summary Collapsible */}
{documentData.content && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
>
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
<CollapsibleTrigger className="w-full flex items-center justify-between p-5 rounded-2xl bg-linear-to-r from-muted/50 to-muted/30 border hover:from-muted/70 hover:to-muted/50 transition-all duration-200">
<span className="font-semibold flex items-center gap-2">
<BookOpen className="h-4 w-4" />
Document Summary
</span>
<motion.div
animate={{ rotate: summaryOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="h-5 w-5 text-muted-foreground" />
</motion.div>
</CollapsibleTrigger>
<CollapsibleContent>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-3 p-5 bg-muted/20 rounded-2xl border"
>
<MarkdownViewer content={documentData.content} />
</motion.div>
</CollapsibleContent>
</Collapsible>
</motion.div>
)}
{/* Chunks Header */}
<div className="flex items-center justify-between pt-4">
<div className="flex items-center justify-between pt-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<Hash className="h-4 w-4" />
Content Chunks
Chunks {absoluteStart + 1}{absoluteEnd} of {totalChunks}
</h3>
{citedChunkIndex !== -1 && (
<Button
@ -576,24 +626,70 @@ export function SourceDetailPanel({
)}
</div>
{/* Load Earlier */}
{canLoadBefore && (
<div className="flex items-center justify-center">
<Button
variant="outline"
size="sm"
onClick={loadBefore}
disabled={loadingBefore}
className="gap-2"
>
{loadingBefore ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ChevronUp className="h-3.5 w-3.5" />
)}
{loadingBefore
? "Loading..."
: `Load ${Math.min(EXPAND_SIZE, absoluteStart)} earlier chunks`}
</Button>
</div>
)}
{/* Chunks */}
<div className="space-y-4">
{documentData.chunks.map((chunk, idx) => {
{allChunks.map((chunk, idx) => {
const isCited = chunk.id === chunkId;
const chunkNumber = absoluteStart + idx + 1;
return (
<ChunkCard
key={chunk.id}
ref={isCited ? citedChunkRefCallback : undefined}
chunk={chunk}
index={idx}
totalChunks={documentData.chunks.length}
localIndex={idx}
chunkNumber={chunkNumber}
totalChunks={totalChunks}
isCited={isCited}
isActive={activeChunkIndex === idx}
disableLayoutAnimation={documentData.chunks.length > 30}
disableLayoutAnimation={allChunks.length > 30}
/>
);
})}
</div>
{/* Load Later */}
{canLoadAfter && (
<div className="flex items-center justify-center py-3">
<Button
variant="outline"
size="sm"
onClick={loadAfter}
disabled={loadingAfter}
className="gap-2"
>
{loadingAfter ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
{loadingAfter
? "Loading..."
: `Load ${Math.min(EXPAND_SIZE, totalChunks - absoluteEnd)} later chunks`}
</Button>
</div>
)}
</div>
</ScrollArea>
</div>

View file

@ -429,6 +429,7 @@ export function OnboardingTour() {
const pathname = usePathname();
const retryCountRef = useRef(0);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startCheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const maxRetries = 10;
// Track previous user ID to detect user changes
const previousUserIdRef = useRef<string | null>(null);
@ -439,8 +440,8 @@ export function OnboardingTour() {
// Fetch threads data
const { data: threadsData } = useQuery({
queryKey: ["threads", searchSpaceId, { limit: 1 }],
queryFn: () => fetchThreads(Number(searchSpaceId), 1), // Only need to check if any exist
queryKey: ["threads", searchSpaceId, { limit: 40 }], // Same key as layout
queryFn: () => fetchThreads(Number(searchSpaceId), 40),
enabled: !!searchSpaceId,
});
@ -460,6 +461,7 @@ export function OnboardingTour() {
// Find and track target element with retry logic
const updateTarget = useCallback(() => {
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
if (!currentStep) return;
const el = document.querySelector(currentStep.target);
@ -480,11 +482,13 @@ export function OnboardingTour() {
}
}, 200);
}
}, [currentStep]);
useEffect(() => {
return () => {
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
};
}, [currentStep]);
}, []);
// Check if tour should run: localStorage + data validation with user ID tracking
useEffect(() => {
@ -573,15 +577,15 @@ export function OnboardingTour() {
setPosition(calculatePosition(connectorEl, TOUR_STEPS[0].placement));
} else {
// Retry after delay
setTimeout(checkAndStartTour, 200);
startCheckTimerRef.current = setTimeout(checkAndStartTour, 200);
}
};
// Start checking after initial delay
const timer = setTimeout(checkAndStartTour, 500);
startCheckTimerRef.current = setTimeout(checkAndStartTour, 500);
return () => {
cancelled = true;
clearTimeout(timer);
if (startCheckTimerRef.current) clearTimeout(startCheckTimerRef.current);
};
}, [mounted, user?.id, searchSpaceId, pathname, threadsData, documentTypeCounts, connectors]);

View file

@ -1,6 +1,6 @@
"use client";
import { Check, Copy, ExternalLink, MessageSquare, Trash2 } from "lucide-react";
import { Check, Copy, Dot, ExternalLink, MessageSquare, Trash2 } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
@ -153,7 +153,7 @@ export function PublicChatSnapshotRow({
<span className="text-[11px] text-muted-foreground/60">{formattedDate}</span>
{member && (
<>
<span className="text-muted-foreground/30">·</span>
<Dot className="h-4 w-4 text-muted-foreground/30" />
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>

View file

@ -11,11 +11,8 @@ export function PublicChatSnapshotsEmptyState({
}: PublicChatSnapshotsEmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-muted p-3 mb-4">
<Link2Off className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-sm font-medium text-foreground mb-1">{title}</h3>
<p className="text-xs text-muted-foreground max-w-sm">{description}</p>
<h3 className="text-sm md:text-base font-semibold mb-2">{title}</h3>
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm">{description}</p>
</div>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { Copy } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@ -15,7 +15,6 @@ interface PublicChatFooterProps {
export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [isCloning, setIsCloning] = useState(false);
const hasAutoCloned = useRef(false);
@ -36,9 +35,11 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
}
}, [shareToken, router]);
// Auto-trigger clone if user just logged in with action=clone
// Auto-trigger clone if user just logged in with action=clone.
// Read from window.location.search on mount — no subscription needed since
// this is a one-time post-login check. (Vercel Best Practice: rerender-defer-reads 5.2)
useEffect(() => {
const action = searchParams.get("action");
const action = new URLSearchParams(window.location.search).get("action");
const token = getBearerToken();
// Only auto-clone once, if authenticated and action=clone is present
@ -46,7 +47,7 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
hasAutoCloned.current = true;
triggerClone();
}
}, [searchParams, isCloning, triggerClone]);
}, [isCloning, triggerClone]);
const handleCopyAndContinue = async () => {
const token = getBearerToken();

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Edit3, Info, Plus, RefreshCw, Trash2, Wand2 } from "lucide-react";
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2, Wand2 } from "lucide-react";
import { useMemo, useState } from "react";
import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
import {
@ -240,27 +240,14 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{!isLoading && (
<div className="space-y-4 md:space-y-6">
{(userConfigs?.length ?? 0) === 0 ? (
<Card className="border-dashed border-2 border-muted-foreground/25">
<Card className="border-0 bg-transparent shadow-none">
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
<div className="rounded-full bg-gradient-to-br from-teal-500/10 to-cyan-500/10 p-4 md:p-6 mb-4">
<Wand2 className="h-8 w-8 md:h-12 md:w-12 text-teal-600 dark:text-teal-400" />
</div>
<h3 className="text-lg font-semibold mb-2">No Image Models Yet</h3>
<p className="text-xs md:text-sm text-muted-foreground max-w-sm mb-4">
<h3 className="text-sm md:text-base font-semibold mb-2">No Image Models Yet</h3>
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
{canCreate
? "Add your own image generation model (DALL-E 3, GPT Image 1, etc.)"
: "No image models have been added to this space yet. Contact a space owner to add one."}
</p>
{canCreate && (
<Button
onClick={openNewDialog}
size="lg"
className="gap-2 text-xs md:text-sm h-9 md:h-10"
>
<Plus className="h-3 w-3 md:h-4 md:w-4" />
Add First Image Model
</Button>
)}
</CardContent>
</Card>
) : (
@ -343,7 +330,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</span>
{member && (
<>
<span className="text-muted-foreground/30">·</span>
<Dot className="h-4 w-4 text-muted-foreground/30" />
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>

View file

@ -4,17 +4,15 @@ import { useAtomValue } from "jotai";
import {
AlertCircle,
Bot,
CheckCircle,
CircleCheck,
CircleDashed,
Eye,
FileText,
ImageIcon,
RefreshCw,
RotateCcw,
Save,
Shuffle,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
globalImageGenConfigsAtom,
@ -41,6 +39,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
@ -49,8 +48,8 @@ const ROLE_DESCRIPTIONS = {
icon: Bot,
title: "Agent LLM",
description: "Primary LLM for chat interactions and agent operations",
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-500/10",
color: "text-muted-foreground",
bgColor: "bg-muted",
prefKey: "agent_llm_id" as const,
configType: "llm" as const,
},
@ -58,8 +57,8 @@ const ROLE_DESCRIPTIONS = {
icon: FileText,
title: "Document Summary LLM",
description: "Handles document summarization and research synthesis",
color: "text-purple-600 dark:text-purple-400",
bgColor: "bg-purple-500/10",
color: "text-muted-foreground",
bgColor: "bg-muted",
prefKey: "document_summary_llm_id" as const,
configType: "llm" as const,
},
@ -67,8 +66,8 @@ const ROLE_DESCRIPTIONS = {
icon: ImageIcon,
title: "Image Generation Model",
description: "Model used for AI image generation (DALL-E, GPT Image, etc.)",
color: "text-teal-600 dark:text-teal-400",
bgColor: "bg-teal-500/10",
color: "text-muted-foreground",
bgColor: "bg-muted",
prefKey: "image_generation_config_id" as const,
configType: "image" as const,
},
@ -129,18 +128,18 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
vision_llm_id: preferences.vision_llm_id ?? "",
}));
const [hasChanges, setHasChanges] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [savingRole, setSavingRole] = useState<string | null>(null);
const savingRef = useRef(false);
useEffect(() => {
const newAssignments = {
agent_llm_id: preferences.agent_llm_id ?? "",
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
image_generation_config_id: preferences.image_generation_config_id ?? "",
vision_llm_id: preferences.vision_llm_id ?? "",
};
setAssignments(newAssignments);
setHasChanges(false);
if (!savingRef.current) {
setAssignments({
agent_llm_id: preferences.agent_llm_id ?? "",
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
image_generation_config_id: preferences.image_generation_config_id ?? "",
vision_llm_id: preferences.vision_llm_id ?? "",
});
}
}, [
preferences?.agent_llm_id,
preferences?.document_summary_llm_id,
@ -148,77 +147,27 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
preferences?.vision_llm_id,
]);
const handleRoleAssignment = (prefKey: string, configId: string) => {
const newAssignments = {
...assignments,
[prefKey]: configId === "unassigned" ? "" : parseInt(configId),
};
const handleRoleAssignment = useCallback(
async (prefKey: string, configId: string) => {
const value = configId === "unassigned" ? "" : parseInt(configId);
setAssignments(newAssignments);
setAssignments((prev) => ({ ...prev, [prefKey]: value }));
setSavingRole(prefKey);
savingRef.current = true;
const currentPrefs = {
agent_llm_id: preferences.agent_llm_id ?? "",
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
image_generation_config_id: preferences.image_generation_config_id ?? "",
vision_llm_id: preferences.vision_llm_id ?? "",
};
const hasChangesNow = Object.keys(newAssignments).some(
(key) =>
newAssignments[key as keyof typeof newAssignments] !==
currentPrefs[key as keyof typeof currentPrefs]
);
setHasChanges(hasChangesNow);
};
const handleSave = async () => {
setIsSaving(true);
const toNumericOrUndefined = (val: string | number) =>
typeof val === "string" ? (val ? parseInt(val) : undefined) : val;
const numericAssignments = {
agent_llm_id: toNumericOrUndefined(assignments.agent_llm_id),
document_summary_llm_id: toNumericOrUndefined(assignments.document_summary_llm_id),
image_generation_config_id: toNumericOrUndefined(assignments.image_generation_config_id),
vision_llm_id: toNumericOrUndefined(assignments.vision_llm_id),
};
await updatePreferences({
search_space_id: searchSpaceId,
data: numericAssignments,
});
setHasChanges(false);
toast.success("Role assignments saved successfully!");
setIsSaving(false);
};
const handleReset = () => {
setAssignments({
agent_llm_id: preferences.agent_llm_id ?? "",
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
image_generation_config_id: preferences.image_generation_config_id ?? "",
vision_llm_id: preferences.vision_llm_id ?? "",
});
setHasChanges(false);
};
const isAssignmentComplete =
assignments.agent_llm_id !== "" &&
assignments.agent_llm_id !== null &&
assignments.agent_llm_id !== undefined &&
assignments.document_summary_llm_id !== "" &&
assignments.document_summary_llm_id !== null &&
assignments.document_summary_llm_id !== undefined &&
assignments.image_generation_config_id !== "" &&
assignments.image_generation_config_id !== null &&
assignments.image_generation_config_id !== undefined &&
assignments.vision_llm_id !== "" &&
assignments.vision_llm_id !== null &&
assignments.vision_llm_id !== undefined;
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: { [prefKey]: value || undefined },
});
toast.success("Role assignment updated");
} finally {
setSavingRole(null);
savingRef.current = false;
}
},
[updatePreferences, searchSpaceId]
);
// Combine global and custom LLM configs
const allLLMConfigs = [
@ -232,6 +181,11 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
...(userImageConfigs ?? []).filter((config) => config.id && config.id.toString().trim() !== ""),
];
const isAssignmentComplete =
allLLMConfigs.some((c) => c.id === assignments.agent_llm_id) &&
allLLMConfigs.some((c) => c.id === assignments.document_summary_llm_id) &&
allImageConfigs.some((c) => c.id === assignments.image_generation_config_id);
const isLoading =
configsLoading ||
preferencesLoading ||
@ -261,11 +215,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
Refresh
</Button>
{isAssignmentComplete && !isLoading && !hasError && (
<Badge
variant="outline"
className="text-xs gap-1.5 border-emerald-500/30 text-emerald-700 dark:text-emerald-300 bg-emerald-500/5"
>
<CheckCircle className="h-3 w-3" />
<Badge variant="outline" className="text-xs gap-1.5 text-muted-foreground">
<CircleCheck className="h-3 w-3" />
All roles assigned
</Badge>
)}
@ -351,10 +302,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
const roleAllConfigs = isImageRole ? allImageConfigs : allLLMConfigs;
const assignedConfig = roleAllConfigs.find((config) => config.id === currentAssignment);
const isAssigned =
currentAssignment !== "" &&
currentAssignment !== null &&
currentAssignment !== undefined;
const isAssigned = !!assignedConfig;
const isAutoMode =
assignedConfig && "is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode;
@ -380,8 +328,10 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
</p>
</div>
</div>
{isAssigned ? (
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" />
{savingRole === role.prefKey ? (
<Spinner size="sm" className="shrink-0 mt-0.5 text-muted-foreground" />
) : isAssigned ? (
<CircleCheck className="w-4 h-4 text-muted-foreground/40 shrink-0 mt-0.5" />
) : (
<CircleDashed className="w-4 h-4 text-muted-foreground/40 shrink-0 mt-0.5" />
)}
@ -393,7 +343,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
Configuration
</Label>
<Select
value={currentAssignment?.toString() || "unassigned"}
value={isAssigned ? currentAssignment.toString() : "unassigned"}
onValueChange={(value) => handleRoleAssignment(role.prefKey, value)}
>
<SelectTrigger className="w-full h-9 md:h-10 text-xs md:text-sm">
@ -423,13 +373,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
>
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap min-w-0">
{isAuto ? (
<Badge
variant="outline"
className="text-[9px] md:text-[10px] shrink-0 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200 dark:border-violet-700"
>
<Shuffle className="size-2 md:size-2.5 mr-0.5" />
AUTO
</Badge>
<Shuffle className="size-3 md:size-3.5 shrink-0 text-muted-foreground" />
) : (
getProviderIcon(config.provider, {
className: "size-3 md:size-3.5 shrink-0",
@ -552,34 +496,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
})}
</div>
)}
{/* Save / Reset Bar */}
{hasChanges && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 p-3 md:p-4">
<p className="text-xs md:text-sm text-muted-foreground">You have unsaved changes</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={isSaving}
className="h-8 text-xs gap-1.5"
>
<RotateCcw className="w-3 h-3" />
Reset
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isSaving}
className="h-8 text-xs gap-1.5"
>
<Save className="w-3 h-3" />
{isSaving ? "Saving…" : "Save Changes"}
</Button>
</div>
</div>
)}
</div>
);
}

View file

@ -3,11 +3,11 @@
import { useAtomValue } from "jotai";
import {
AlertCircle,
Dot,
Edit3,
FileText,
Info,
MessageSquareQuote,
Plus,
RefreshCw,
Trash2,
Wand2,
@ -151,7 +151,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
onClick={openNewDialog}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
Add LLM Model
Add Model
</Button>
)}
</div>
@ -251,29 +251,14 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<div className="space-y-4">
{configs?.length === 0 ? (
<div>
<Card className="border-dashed border-2 border-muted-foreground/25">
<Card className="border-0 bg-transparent shadow-none">
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
<div className="rounded-full bg-gradient-to-br from-violet-500/10 to-purple-500/10 p-4 md:p-6 mb-4 md:mb-6">
<Wand2 className="h-8 w-8 md:h-12 md:w-12 text-violet-600 dark:text-violet-400" />
</div>
<div className="space-y-2 mb-4 md:mb-6">
<h3 className="text-lg md:text-xl font-semibold">No Configurations Yet</h3>
<p className="text-xs md:text-sm text-muted-foreground max-w-sm">
{canCreate
? "Create your first AI configuration to customize how your agent responds"
: "No AI configurations have been added to this space yet. Contact a space owner to add one."}
</p>
</div>
{canCreate && (
<Button
onClick={openNewDialog}
size="lg"
className="gap-2 text-xs md:text-sm h-9 md:h-10"
>
<Plus className="h-3 w-3 md:h-4 md:w-4" />
Create First Configuration
</Button>
)}
<h3 className="text-sm md:text-base font-semibold mb-2">No Models Yet</h3>
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
{canCreate
? "Add your first model to power document summarization, chat, and other agent capabilities"
: "No models have been added to this space yet. Contact a space owner to add one"}
</p>
</CardContent>
</Card>
</div>
@ -380,7 +365,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
</span>
{member && (
<>
<span className="text-muted-foreground/30">·</span>
<Dot className="h-4 w-4 text-muted-foreground/30" />
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
@ -436,7 +421,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
>
<AlertDialogContent className="select-none">
<AlertDialogHeader>
<AlertDialogTitle>Delete LLM Model</AlertDialogTitle>
<AlertDialogTitle>Delete Model</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{configToDelete?.name}</span>? This

View file

@ -1,33 +1,41 @@
"use client";
import { useAtom } from "jotai";
import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react";
import { ChevronDown, Dot, File as FileIcon, FolderOpen, Upload, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react";
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { SummaryConfig } from "@/components/assistant-ui/connector-popup/components/summary-config";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import {
trackDocumentUploadFailure,
trackDocumentUploadStarted,
trackDocumentUploadSuccess,
} from "@/lib/posthog/events";
import { GridPattern } from "./GridPattern";
interface SelectedFolder {
path: string;
name: string;
}
interface DocumentUploadTabProps {
searchSpaceId: string;
@ -51,6 +59,7 @@ const commonTypes = {
"application/vnd.openxmlformats-officedocument.presentationml.presentation": [".pptx"],
"text/html": [".html", ".htm"],
"text/csv": [".csv"],
"text/tab-separated-values": [".tsv"],
"image/jpeg": [".jpg", ".jpeg"],
"image/png": [".png"],
"image/bmp": [".bmp"],
@ -76,7 +85,6 @@ const FILE_TYPE_CONFIG: Record<string, Record<string, string[]>> = {
"application/rtf": [".rtf"],
"application/xml": [".xml"],
"application/epub+zip": [".epub"],
"text/tab-separated-values": [".tsv"],
"text/html": [".html", ".htm", ".web"],
"image/gif": [".gif"],
"image/svg+xml": [".svg"],
@ -102,7 +110,6 @@ const FILE_TYPE_CONFIG: Record<string, Record<string, string[]>> = {
"application/vnd.ms-powerpoint": [".ppt"],
"text/x-rst": [".rst"],
"application/rtf": [".rtf"],
"text/tab-separated-values": [".tsv"],
"application/vnd.ms-excel": [".xls"],
"application/xml": [".xml"],
...audioFileTypes,
@ -114,12 +121,11 @@ interface FileWithId {
file: File;
}
const cardClass = "border border-border bg-slate-400/5 dark:bg-white/5";
const MAX_FILE_SIZE_MB = 500;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
// Upload limits — files are sent in batches of 5 to avoid proxy timeouts
const MAX_FILES = 50;
const MAX_TOTAL_SIZE_MB = 200;
const MAX_TOTAL_SIZE_BYTES = MAX_TOTAL_SIZE_MB * 1024 * 1024;
const toggleRowClass =
"flex items-center justify-between rounded-lg bg-slate-400/5 dark:bg-white/5 p-3";
export function DocumentUploadTab({
searchSpaceId,
@ -134,6 +140,21 @@ export function DocumentUploadTab({
const [uploadDocumentMutation] = useAtom(uploadDocumentMutationAtom);
const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation;
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const progressIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
return () => {
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
};
}, []);
const [selectedFolder, setSelectedFolder] = useState<SelectedFolder | null>(null);
const [watchFolder, setWatchFolder] = useState(true);
const [folderSubmitting, setFolderSubmitting] = useState(false);
const isElectron = typeof window !== "undefined" && !!window.electronAPI?.browseFiles;
const acceptedFileTypes = useMemo(() => {
const etlService = process.env.NEXT_PUBLIC_ETL_SERVICE;
@ -145,49 +166,106 @@ export function DocumentUploadTab({
[acceptedFileTypes]
);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const supportedExtensionsSet = useMemo(
() => new Set(supportedExtensions.map((ext) => ext.toLowerCase())),
[supportedExtensions]
);
const addFiles = useCallback(
(incoming: File[]) => {
const oversized = incoming.filter((f) => f.size > MAX_FILE_SIZE_BYTES);
if (oversized.length > 0) {
toast.error(t("file_too_large"), {
description: t("file_too_large_desc", {
name: oversized[0].name,
maxMB: MAX_FILE_SIZE_MB,
}),
});
}
const valid = incoming.filter((f) => f.size <= MAX_FILE_SIZE_BYTES);
if (valid.length === 0) return;
setFiles((prev) => {
const newEntries = acceptedFiles.map((f) => ({
const newEntries = valid.map((f) => ({
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
file: f,
}));
const newFiles = [...prev, ...newEntries];
if (newFiles.length > MAX_FILES) {
toast.error(t("max_files_exceeded"), {
description: t("max_files_exceeded_desc", { max: MAX_FILES }),
});
return prev;
}
const newTotalSize = newFiles.reduce((sum, entry) => sum + entry.file.size, 0);
if (newTotalSize > MAX_TOTAL_SIZE_BYTES) {
toast.error(t("max_size_exceeded"), {
description: t("max_size_exceeded_desc", { max: MAX_TOTAL_SIZE_MB }),
});
return prev;
}
return newFiles;
return [...prev, ...newEntries];
});
},
[t]
);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
setSelectedFolder(null);
addFiles(acceptedFiles);
},
[addFiles]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: acceptedFileTypes,
maxSize: 50 * 1024 * 1024, // 50MB per file
noClick: false,
disabled: files.length >= MAX_FILES,
maxSize: MAX_FILE_SIZE_BYTES,
noClick: isElectron,
});
// Handle file input click to prevent event bubbling that might reopen dialog
const handleFileInputClick = useCallback((e: React.MouseEvent<HTMLInputElement>) => {
e.stopPropagation();
}, []);
const handleBrowseFiles = useCallback(async () => {
const api = window.electronAPI;
if (!api?.browseFiles) return;
const paths = await api.browseFiles();
if (!paths || paths.length === 0) return;
setSelectedFolder(null);
const fileDataList = await api.readLocalFiles(paths);
const newFiles: FileWithId[] = fileDataList.map((fd) => ({
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
file: new File([fd.data], fd.name, { type: fd.mimeType }),
}));
setFiles((prev) => [...prev, ...newFiles]);
}, []);
const handleBrowseFolder = useCallback(async () => {
const api = window.electronAPI;
if (!api?.selectFolder) return;
const folderPath = await api.selectFolder();
if (!folderPath) return;
const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath;
setFiles([]);
setSelectedFolder({ path: folderPath, name: folderName });
setWatchFolder(true);
}, []);
const handleFolderChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const fileList = e.target.files;
if (!fileList || fileList.length === 0) return;
const folderFiles = Array.from(fileList).filter((f) => {
const ext = f.name.includes(".") ? `.${f.name.split(".").pop()?.toLowerCase()}` : "";
return ext !== "" && supportedExtensionsSet.has(ext);
});
if (folderFiles.length === 0) {
toast.error(t("no_supported_files_in_folder"));
e.target.value = "";
return;
}
addFiles(folderFiles);
e.target.value = "";
},
[addFiles, supportedExtensionsSet, t]
);
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
@ -198,16 +276,8 @@ export function DocumentUploadTab({
const totalFileSize = files.reduce((total, entry) => total + entry.file.size, 0);
// Check if limits are reached
const isFileCountLimitReached = files.length >= MAX_FILES;
const isSizeLimitReached = totalFileSize >= MAX_TOTAL_SIZE_BYTES;
const remainingFiles = MAX_FILES - files.length;
const remainingSizeMB = Math.max(
0,
(MAX_TOTAL_SIZE_BYTES - totalFileSize) / (1024 * 1024)
).toFixed(1);
const hasContent = files.length > 0 || selectedFolder !== null;
// Track accordion state changes
const handleAccordionChange = useCallback(
(value: string) => {
setAccordionValue(value);
@ -216,11 +286,59 @@ export function DocumentUploadTab({
[onAccordionStateChange]
);
const handleFolderSubmit = useCallback(async () => {
if (!selectedFolder) return;
const api = window.electronAPI;
if (!api) return;
setFolderSubmitting(true);
try {
const numericSpaceId = Number(searchSpaceId);
const result = await documentsApiService.folderIndex(numericSpaceId, {
folder_path: selectedFolder.path,
folder_name: selectedFolder.name,
search_space_id: numericSpaceId,
enable_summary: shouldSummarize,
});
const rootFolderId = (result as { root_folder_id?: number })?.root_folder_id ?? null;
if (watchFolder) {
await api.addWatchedFolder({
path: selectedFolder.path,
name: selectedFolder.name,
excludePatterns: [
".git",
"node_modules",
"__pycache__",
".DS_Store",
".obsidian",
".trash",
],
fileExtensions: null,
rootFolderId,
searchSpaceId: Number(searchSpaceId),
active: true,
});
toast.success(`Watching folder: ${selectedFolder.name}`);
} else {
toast.success(`Syncing folder: ${selectedFolder.name}`);
}
setSelectedFolder(null);
onSuccess?.();
} catch (err) {
toast.error((err as Error)?.message || "Failed to process folder");
} finally {
setFolderSubmitting(false);
}
}, [selectedFolder, watchFolder, searchSpaceId, shouldSummarize, onSuccess]);
const handleUpload = async () => {
setUploadProgress(0);
trackDocumentUploadStarted(Number(searchSpaceId), files.length, totalFileSize);
const progressInterval = setInterval(() => {
progressIntervalRef.current = setInterval(() => {
setUploadProgress((prev) => (prev >= 90 ? prev : prev + Math.random() * 10));
}, 200);
@ -233,14 +351,14 @@ export function DocumentUploadTab({
},
{
onSuccess: () => {
clearInterval(progressInterval);
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
setUploadProgress(100);
trackDocumentUploadSuccess(Number(searchSpaceId), files.length);
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
onSuccess?.();
},
onError: (error: unknown) => {
clearInterval(progressInterval);
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
setUploadProgress(0);
const message = error instanceof Error ? error.message : "Upload failed";
trackDocumentUploadFailure(Number(searchSpaceId), message);
@ -252,207 +370,322 @@ export function DocumentUploadTab({
);
};
return (
<div className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0">
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
{t("file_size_limit")}{" "}
{t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })}
</AlertDescription>
</Alert>
const renderBrowseButton = (options?: { compact?: boolean; fullWidth?: boolean }) => {
const { compact, fullWidth } = options ?? {};
const sizeClass = compact ? "h-7" : "h-8";
const widthClass = fullWidth ? "w-full" : "";
<Card className={`relative overflow-hidden ${cardClass}`}>
<div className="absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,white,transparent)] opacity-30">
<GridPattern />
</div>
<CardContent className="p-4 sm:p-10 relative z-10">
<div
{...getRootProps()}
className={`flex flex-col items-center justify-center min-h-[200px] sm:min-h-[300px] border-2 border-dashed rounded-lg transition-colors ${
isFileCountLimitReached || isSizeLimitReached
? "border-destructive/50 bg-destructive/5 cursor-not-allowed"
: "border-border hover:border-primary/50 cursor-pointer"
}`}
if (isElectron) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
className={`text-xs gap-1 bg-neutral-700/50 hover:bg-neutral-600/50 ${sizeClass} ${widthClass}`}
>
Browse
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="dark:bg-neutral-800"
onClick={(e) => e.stopPropagation()}
>
<input
{...getInputProps()}
ref={fileInputRef}
className="hidden"
onClick={handleFileInputClick}
/>
{isFileCountLimitReached ? (
<div className="flex flex-col items-center gap-2 sm:gap-4 text-center px-4">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-destructive/70" />
<div>
<p className="text-sm sm:text-lg font-medium text-destructive">
{t("file_limit_reached")}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
{t("file_limit_reached_desc", { max: MAX_FILES })}
</p>
</div>
</div>
) : isDragActive ? (
<div className="flex flex-col items-center gap-2 sm:gap-4">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" />
<p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p>
</div>
) : (
<div className="flex flex-col items-center gap-2 sm:gap-4">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" />
<div className="text-center">
<p className="text-sm sm:text-lg font-medium">{t("drag_drop")}</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">{t("or_browse")}</p>
</div>
{files.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("remaining_capacity", { files: remainingFiles, sizeMB: remainingSizeMB })}
</p>
)}
</div>
)}
{!isFileCountLimitReached && (
<div className="mt-2 sm:mt-4">
<Button
variant="secondary"
size="sm"
className="text-xs sm:text-sm"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
fileInputRef.current?.click();
}}
>
{t("browse_files")}
</Button>
</div>
)}
<DropdownMenuItem onClick={handleBrowseFiles}>
<FileIcon className="h-4 w-4 mr-2" />
Files
</DropdownMenuItem>
<DropdownMenuItem onClick={handleBrowseFolder}>
<FolderOpen className="h-4 w-4 mr-2" />
Folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
className={`text-xs gap-1 bg-neutral-700/50 hover:bg-neutral-600/50 ${sizeClass} ${widthClass}`}
>
Browse
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="dark:bg-neutral-800"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem onClick={() => fileInputRef.current?.click()}>
<FileIcon className="h-4 w-4 mr-2" />
{t("browse_files")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => folderInputRef.current?.click()}>
<FolderOpen className="h-4 w-4 mr-2" />
{t("browse_folder")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
return (
<div className="space-y-2 w-full mx-auto">
{/* Hidden file input */}
<input
{...getInputProps()}
ref={fileInputRef}
className="hidden"
onClick={handleFileInputClick}
/>
{/* Hidden folder input for web folder browsing */}
<input
ref={folderInputRef}
type="file"
className="hidden"
onChange={handleFolderChange}
multiple
{...({ webkitdirectory: "", directory: "" } as React.InputHTMLAttributes<HTMLInputElement>)}
/>
{/* MOBILE DROP ZONE */}
<div className="sm:hidden">
{hasContent ? (
!selectedFolder &&
(isElectron ? (
<div className="w-full">{renderBrowseButton({ compact: true, fullWidth: true })}</div>
) : (
<button
type="button"
className="w-full text-xs h-8 flex items-center justify-center gap-1.5 rounded-md border border-dashed border-muted-foreground/30 text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
Add more files
</button>
))
) : (
<div
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer"
onClick={() => {
if (!isElectron) fileInputRef.current?.click();
}}
>
<Upload className="h-10 w-10 text-muted-foreground" />
<div className="text-center space-y-1.5">
<p className="text-base font-medium">
{isElectron ? "Select files or folder" : "Tap to select files or folder"}
</p>
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
</div>
<div className="w-full mt-1" onClick={(e) => e.stopPropagation()}>
{renderBrowseButton({ fullWidth: true })}
</div>
</div>
</CardContent>
</Card>
)}
</div>
{files.length > 0 && (
<Card className={cardClass}>
<CardHeader className="p-4 sm:p-6">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="text-base sm:text-2xl">
{t("selected_files", { count: files.length })}
</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{t("total_size")}: {formatFileSize(totalFileSize)}
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
className="text-xs sm:text-sm shrink-0"
onClick={() => setFiles([])}
disabled={isUploading}
>
{t("clear_all")}
</Button>
</div>
</CardHeader>
<CardContent className="p-4 sm:p-6 pt-0">
<div className="space-y-2 sm:space-y-3 max-h-[250px] sm:max-h-[400px] overflow-y-auto">
{files.map((entry) => (
<div
key={entry.id}
className={`flex items-center justify-between p-2 sm:p-4 rounded-lg border border-border ${cardClass} hover:bg-slate-400/10 dark:hover:bg-white/10 transition-colors`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileType className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium truncate">{entry.file.name}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{formatFileSize(entry.file.size)}
</Badge>
<Badge variant="outline" className="text-xs">
{entry.file.type || "Unknown type"}
</Badge>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setFiles((prev) => prev.filter((e) => e.id !== entry.id))}
disabled={isUploading}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
{isUploading && (
<div className="mt-3 sm:mt-6 space-y-2 sm:space-y-3">
<Separator className="bg-border" />
<div className="space-y-2">
<div className="flex items-center justify-between text-xs sm:text-sm">
<span>{t("uploading_files")}</span>
<span>{Math.round(uploadProgress)}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
</div>
{/* DESKTOP DROP ZONE */}
<div
{...getRootProps()}
className={`hidden sm:block border-2 border-dashed rounded-lg transition-colors border-muted-foreground/30 hover:border-foreground/70 cursor-pointer ${hasContent ? "p-3" : "py-20 px-4"}`}
>
{hasContent ? (
<div className="flex items-center gap-3">
<Upload className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground flex-1 truncate">
{isDragActive ? t("drop_files") : t("drag_drop_more")}
</span>
{renderBrowseButton({ compact: true })}
</div>
) : (
<div className="relative">
{isDragActive && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<Upload className="h-8 w-8 text-primary" />
<p className="text-sm font-medium text-primary">{t("drop_files")}</p>
</div>
)}
<div className="mt-3 sm:mt-6">
<SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} />
<div className={`flex flex-col items-center gap-2 ${isDragActive ? "invisible" : ""}`}>
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium">{t("drag_drop")}</p>
<p className="text-xs text-muted-foreground">{t("file_size_limit")}</p>
<div className="mt-1">{renderBrowseButton()}</div>
</div>
</div>
)}
</div>
<div className="mt-3 sm:mt-6">
<Button
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium"
onClick={handleUpload}
disabled={isUploading || files.length === 0}
>
{isUploading ? (
<span className="flex items-center gap-2">
<Spinner size="sm" />
{t("uploading")}
</span>
) : (
<span className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
{t("upload_button", { count: files.length })}
</span>
)}
</Button>
{/* FOLDER SELECTED (Electron only — web flattens folder contents into file list) */}
{isElectron && selectedFolder && (
<div className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-center gap-2 py-1.5 px-2 -mx-1 rounded-md hover:bg-slate-400/5 dark:hover:bg-white/5 group">
<FolderOpen className="h-4 w-4 text-primary shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{selectedFolder.name}</p>
<p className="text-xs text-muted-foreground truncate">{selectedFolder.path}</p>
</div>
</CardContent>
</Card>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setSelectedFolder(null)}
disabled={folderSubmitting}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="rounded-lg bg-slate-400/5 dark:bg-white/5 divide-y divide-border">
<div className="flex items-center justify-between p-3">
<div className="space-y-0.5">
<p className="font-medium text-sm">Watch folder</p>
<p className="text-xs text-muted-foreground">Auto-sync when files change</p>
</div>
<Switch
id="watch-folder-toggle"
checked={watchFolder}
onCheckedChange={setWatchFolder}
/>
</div>
<div className="flex items-center justify-between p-3">
<div className="space-y-0.5">
<p className="font-medium text-sm">Enable AI Summary</p>
<p className="text-xs text-muted-foreground">
Improves search quality but adds latency
</p>
</div>
<Switch checked={shouldSummarize} onCheckedChange={setShouldSummarize} />
</div>
</div>
<Button
className="w-full relative"
onClick={handleFolderSubmit}
disabled={folderSubmitting}
>
<span className={folderSubmitting ? "invisible" : ""}>
{watchFolder ? "Sync & Watch for Changes" : "Sync Folder"}
</span>
{folderSubmitting && (
<span className="absolute inset-0 flex items-center justify-center">
<Spinner size="sm" />
</span>
)}
</Button>
</div>
)}
{/* FILES SELECTED */}
{files.length > 0 && (
<div className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">
{t("selected_files", { count: files.length })}
<Dot className="inline h-4 w-4" />
{formatFileSize(totalFileSize)}
</p>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setFiles([])}
disabled={isUploading}
>
{t("clear_all")}
</Button>
</div>
<div className="max-h-[160px] sm:max-h-[200px] overflow-y-auto -mx-1">
{files.map((entry) => (
<div
key={entry.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-slate-400/5 dark:hover:bg-white/5 group"
>
<span className="text-[10px] font-medium uppercase leading-none bg-muted px-1.5 py-0.5 rounded text-muted-foreground shrink-0">
{entry.file.name.split(".").pop() || "?"}
</span>
<span className="text-sm truncate flex-1 min-w-0">{entry.file.name}</span>
<span className="text-xs text-muted-foreground shrink-0">
{formatFileSize(entry.file.size)}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => setFiles((prev) => prev.filter((e) => e.id !== entry.id))}
disabled={isUploading}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{isUploading && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span>{t("uploading_files")}</span>
<span>{Math.round(uploadProgress)}%</span>
</div>
<Progress value={uploadProgress} className="h-1.5" />
</div>
)}
<div className={toggleRowClass}>
<div className="space-y-0.5">
<p className="font-medium text-sm">Enable AI Summary</p>
<p className="text-xs text-muted-foreground">
Improves search quality but adds latency
</p>
</div>
<Switch checked={shouldSummarize} onCheckedChange={setShouldSummarize} />
</div>
<Button
className="w-full"
onClick={handleUpload}
disabled={isUploading || files.length === 0}
>
{isUploading ? (
<span className="flex items-center gap-2">
<Spinner size="sm" />
{t("uploading")}
</span>
) : (
<span className="flex items-center gap-2">
{t("upload_button", { count: files.length })}
</span>
)}
</Button>
</div>
)}
{/* SUPPORTED FORMATS */}
<Accordion
type="single"
collapsible
value={accordionValue}
onValueChange={handleAccordionChange}
className={`w-full ${cardClass} border border-border rounded-lg mb-0`}
className="w-full mt-5"
>
<AccordionItem value="supported-file-types" className="border-0">
<AccordionTrigger className="px-3 sm:px-6 py-3 sm:py-4 hover:no-underline !items-center [&>svg]:!translate-y-0">
<div className="flex items-center gap-2 flex-1">
<div className="text-left min-w-0">
<div className="font-semibold text-sm sm:text-base">
{t("supported_file_types")}
</div>
<div className="text-xs sm:text-sm text-muted-foreground font-normal">
{t("file_types_desc")}
</div>
</div>
</div>
<AccordionItem value="supported-file-types" className="border border-border rounded-lg">
<AccordionTrigger className="px-3 py-2.5 hover:no-underline !items-center [&>svg]:!translate-y-0">
<span className="text-xs sm:text-sm text-muted-foreground font-normal">
{t("supported_file_types")}
</span>
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6">
<div className="flex flex-wrap gap-2">
<AccordionContent className="px-3 pb-3">
<div className="flex flex-wrap gap-1">
{supportedExtensions.map((ext) => (
<Badge key={ext} variant="outline" className="text-xs">
<Badge key={ext} variant="outline" className="text-[10px] px-1.5 py-0">
{ext}
</Badge>
))}

View file

@ -7,6 +7,8 @@ import { openSafeNavigationHref, resolveSafeNavigationHref } from "../shared/med
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
import { Citation } from "./citation";
import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
import NextImage from 'next/image';
const TYPE_ICONS: Record<CitationType, LucideIcon> = {
webpage: Globe,
@ -253,18 +255,18 @@ function OverflowItem({ citation, onClick }: OverflowItemProps) {
className="group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none"
>
{citation.favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
<img
<NextImage
src={citation.favicon}
alt=""
aria-hidden="true"
width={16}
height={16}
className="bg-muted size-4 shrink-0 rounded object-cover"
width={18}
height={18}
className="size-4.5 rounded-full object-cover"
unoptimized={true}
/>
) : (
<TypeIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
)}
) : (
<TypeIcon className="text-muted-foreground size-3" aria-hidden="true" />
)}
<div className="min-w-0 flex-1">
<p className="group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2">
{citation.title}
@ -339,18 +341,18 @@ function StackedCitations({ id, citations, className, onNavigate }: StackedCitat
style={{ zIndex: maxIcons - index }}
>
{citation.favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
<img
src={citation.favicon}
alt=""
aria-hidden="true"
width={18}
height={18}
className="size-4.5 rounded-full object-cover"
/>
) : (
<TypeIcon className="text-muted-foreground size-3" aria-hidden="true" />
)}
<NextImage
src={citation.favicon}
alt=""
aria-hidden="true"
width={18}
height={18}
className="size-4.5 rounded-full object-cover"
unoptimized={true}
/>
) : (
<TypeIcon className="text-muted-foreground size-3" aria-hidden="true" />
)}
</div>
);
})}

View file

@ -6,6 +6,7 @@ import * as React from "react";
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
import type { CitationType, CitationVariant, SerializableCitation } from "./schema";
import NextImage from 'next/image';
const FALLBACK_LOCALE = "en-US";
@ -114,18 +115,18 @@ export function Citation(props: CitationProps) {
};
const iconElement = favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
<img
src={favicon}
alt=""
aria-hidden="true"
width={14}
height={14}
className="bg-muted size-3.5 shrink-0 rounded object-cover"
/>
) : (
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
);
<NextImage
src={favicon}
alt=""
aria-hidden="true"
width={16}
height={16}
className="bg-muted size-3.5 shrink-0 rounded object-cover"
unoptimized={true}
/>
) : (
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
);
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();

View file

@ -1,7 +1,7 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View file

@ -47,7 +47,7 @@ function ContextMenuSubTrigger({
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8",
className
)}
{...props}

View file

@ -1,7 +1,7 @@
"use client";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
@ -182,7 +182,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-neutral-200 focus:text-accent-foreground dark:focus:bg-neutral-700 data-[state=open]:bg-neutral-200 data-[state=open]:text-accent-foreground dark:data-[state=open]:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-neutral-200 focus:text-accent-foreground dark:focus:bg-neutral-700 data-[state=open]:bg-neutral-200 data-[state=open]:text-accent-foreground dark:data-[state=open]:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View file

@ -1,6 +1,6 @@
"use client";
import { Separator as SeparatorPrimitive } from "radix-ui";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import type * as React from "react";
import { cn } from "@/lib/utils";

View file

@ -1,7 +1,7 @@
"use client";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import type { VariantProps } from "class-variance-authority";
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
import * as React from "react";
import { toggleVariants } from "@/components/ui/toggle";
import { cn } from "@/lib/utils";

View file

@ -1,7 +1,7 @@
"use client";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { Toggle as TogglePrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";

View file

@ -1,6 +1,6 @@
"use client";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type * as React from "react";
import { cn } from "@/lib/utils";

View file

@ -10,7 +10,7 @@ SurfSense uses [pytest](https://docs.pytest.org/) with two test layers: **unit**
- **PostgreSQL + pgvector** running locally (database `surfsense_test` will be used)
- **`REGISTRATION_ENABLED=TRUE`** in your `.env` (this is the default)
- A working LLM model with a valid API key in `global_llm_config.yaml` (for integration tests)
- A working model with a valid API key in `global_llm_config.yaml` (for integration tests)
No Redis or Celery is required — integration tests use an inline task dispatcher.

View file

@ -126,6 +126,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <Microscope {...iconProps} />;
case "DEEPEST":
return <Telescope {...iconProps} />;
case "LOCAL_FOLDER_FILE":
return null;
default:
return <Search {...iconProps} />;
}

View file

@ -5,7 +5,7 @@ export interface LLMModel {
contextWindow?: string;
}
// Comprehensive LLM models database organized by provider
// Comprehensive models database organized by provider
export const LLM_MODELS: LLMModel[] = [
// OpenAI
{

View file

@ -26,6 +26,7 @@ export const documentTypeEnum = z.enum([
"BOOKSTACK_CONNECTOR",
"CIRCLEBACK",
"OBSIDIAN_CONNECTOR",
"LOCAL_FOLDER_FILE",
"SURFSENSE_DOCS",
"NOTE",
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
@ -39,6 +40,7 @@ export const document = z.object({
document_type: documentTypeEnum,
document_metadata: z.record(z.string(), z.any()),
content: z.string(),
content_preview: z.string().optional().default(""),
content_hash: z.string(),
unique_identifier_hash: z.string().nullable(),
created_at: z.string(),
@ -69,6 +71,8 @@ export const documentWithChunks = document.extend({
created_at: z.string(),
})
),
total_chunks: z.number().optional().default(0),
chunk_start_index: z.number().optional().default(0),
});
/**
@ -243,10 +247,36 @@ export const getDocumentTypeCountsResponse = z.record(z.string(), z.number());
*/
export const getDocumentByChunkRequest = z.object({
chunk_id: z.number(),
chunk_window: z.number().optional(),
});
export const getDocumentByChunkResponse = documentWithChunks;
/**
* Get paginated chunks for a document
*/
export const getDocumentChunksRequest = z.object({
document_id: z.number(),
page: z.number().optional().default(0),
page_size: z.number().optional().default(20),
start_offset: z.number().optional(),
});
export const chunkRead = z.object({
id: z.number(),
content: z.string(),
document_id: z.number(),
created_at: z.string(),
});
export const getDocumentChunksResponse = z.object({
items: z.array(chunkRead),
total: z.number(),
page: z.number(),
page_size: z.number(),
has_more: z.boolean(),
});
/**
* Get Surfsense docs by chunk
*/
@ -328,3 +358,6 @@ export type GetSurfsenseDocsByChunkRequest = z.infer<typeof getSurfsenseDocsByCh
export type GetSurfsenseDocsByChunkResponse = z.infer<typeof getSurfsenseDocsByChunkResponse>;
export type GetSurfsenseDocsRequest = z.infer<typeof getSurfsenseDocsRequest>;
export type GetSurfsenseDocsResponse = z.infer<typeof getSurfsenseDocsResponse>;
export type GetDocumentChunksRequest = z.infer<typeof getDocumentChunksRequest>;
export type GetDocumentChunksResponse = z.infer<typeof getDocumentChunksResponse>;
export type ChunkRead = z.infer<typeof chunkRead>;

View file

@ -9,6 +9,7 @@ export const folder = z.object({
created_by_id: z.string().nullable().optional(),
created_at: z.string(),
updated_at: z.string(),
metadata: z.record(z.unknown()).nullable().optional(),
});
export const folderCreateRequest = z.object({

View file

@ -41,14 +41,14 @@ export const liteLLMProviderEnum = z.enum([
export type LiteLLMProvider = z.infer<typeof liteLLMProviderEnum>;
/**
* NewLLMConfig - combines LLM model settings with prompt configuration
* NewLLMConfig - combines model settings with prompt configuration
*/
export const newLLMConfig = z.object({
id: z.number(),
name: z.string().max(100),
description: z.string().max(500).nullable().optional(),
// LLM Model Configuration
// Model Configuration
provider: liteLLMProviderEnum,
custom_provider: z.string().max(100).nullable().optional(),
model_name: z.string().max(100),
@ -148,7 +148,7 @@ export const globalNewLLMConfig = z.object({
name: z.string(),
description: z.string().nullable().optional(),
// LLM Model Configuration (no api_key)
// Model Configuration (no api_key)
provider: z.string(), // String because YAML doesn't enforce enum, "AUTO" for Auto mode
custom_provider: z.string().nullable().optional(),
model_name: z.string(),

View file

@ -118,8 +118,8 @@ function transformComments(
for (const [messageId, group] of byMessage) {
const comments: Comment[] = group.topLevel.map((raw) => {
const replies = (group.replies.get(raw.id) || [])
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
const replies = (group.replies.get(raw.id) ?? [])
.toSorted((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.map((r) => transformReply(r, memberMap, currentUserId, isOwner));
return {

View file

@ -246,9 +246,11 @@ export function useDocuments(
status: (doc.status as unknown as DocumentStatusType) ?? { state: "ready" },
}));
const liveById = new Map(validItems.map((v) => [v.id, v]));
let updated = prev.map((existing) => {
if (liveIds.has(existing.id)) {
const liveItem = validItems.find((v) => v.id === existing.id);
const liveItem = liveById.get(existing.id);
if (liveItem) {
return {
...existing,

View file

@ -0,0 +1,153 @@
"use client";
import { useEffect, useRef } from "react";
import { documentsApiService } from "@/lib/apis/documents-api.service";
interface FileChangedEvent {
id: string;
rootFolderId: number | null;
searchSpaceId: number;
folderPath: string;
folderName: string;
relativePath: string;
fullPath: string;
action: string;
timestamp: number;
}
const DEBOUNCE_MS = 2000;
const MAX_WAIT_MS = 10_000;
const MAX_BATCH_SIZE = 50;
interface BatchItem {
folderPath: string;
folderName: string;
searchSpaceId: number;
rootFolderId: number | null;
filePaths: string[];
ackIds: string[];
}
export function useFolderSync() {
const queueRef = useRef<BatchItem[]>([]);
const processingRef = useRef(false);
const debounceTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const pendingByFolder = useRef<Map<string, BatchItem>>(new Map());
const firstEventTime = useRef<Map<string, number>>(new Map());
const isMountedRef = useRef(false);
async function processQueue() {
if (processingRef.current) return;
processingRef.current = true;
while (queueRef.current.length > 0) {
const batch = queueRef.current.shift()!;
try {
await documentsApiService.folderIndexFiles(batch.searchSpaceId, {
folder_path: batch.folderPath,
folder_name: batch.folderName,
search_space_id: batch.searchSpaceId,
target_file_paths: batch.filePaths,
root_folder_id: batch.rootFolderId,
});
const api = typeof window !== "undefined" ? window.electronAPI : null;
if (api?.acknowledgeFileEvents && batch.ackIds.length > 0) {
await api.acknowledgeFileEvents(batch.ackIds);
}
} catch (err) {
console.error("[FolderSync] Failed to trigger batch re-index:", err);
}
}
processingRef.current = false;
}
function flushFolder(folderKey: string) {
debounceTimers.current.delete(folderKey);
firstEventTime.current.delete(folderKey);
const pending = pendingByFolder.current.get(folderKey);
if (!pending) return;
pendingByFolder.current.delete(folderKey);
for (let i = 0; i < pending.filePaths.length; i += MAX_BATCH_SIZE) {
queueRef.current.push({
...pending,
filePaths: pending.filePaths.slice(i, i + MAX_BATCH_SIZE),
ackIds: i === 0 ? pending.ackIds : [],
});
}
processQueue();
}
function enqueueWithDebounce(event: FileChangedEvent) {
const folderKey = event.folderPath;
const existing = pendingByFolder.current.get(folderKey);
if (existing) {
const pathSet = new Set(existing.filePaths);
pathSet.add(event.fullPath);
existing.filePaths = Array.from(pathSet);
if (!existing.ackIds.includes(event.id)) {
existing.ackIds.push(event.id);
}
} else {
pendingByFolder.current.set(folderKey, {
folderPath: event.folderPath,
folderName: event.folderName,
searchSpaceId: event.searchSpaceId,
rootFolderId: event.rootFolderId,
filePaths: [event.fullPath],
ackIds: [event.id],
});
firstEventTime.current.set(folderKey, Date.now());
}
const elapsed = Date.now() - (firstEventTime.current.get(folderKey) ?? Date.now());
if (elapsed >= MAX_WAIT_MS) {
const existingTimeout = debounceTimers.current.get(folderKey);
if (existingTimeout) clearTimeout(existingTimeout);
flushFolder(folderKey);
return;
}
const existingTimeout = debounceTimers.current.get(folderKey);
if (existingTimeout) clearTimeout(existingTimeout);
const timeout = setTimeout(() => flushFolder(folderKey), DEBOUNCE_MS);
debounceTimers.current.set(folderKey, timeout);
}
useEffect(() => {
isMountedRef.current = true;
const api = typeof window !== "undefined" ? window.electronAPI : null;
if (!api?.onFileChanged) {
return () => {
isMountedRef.current = false;
};
}
// Signal to main process that the renderer is ready to receive events
api.signalRendererReady?.();
// Drain durable outbox first so events survive renderer startup gaps and restarts
void api.getPendingFileEvents?.().then((pendingEvents) => {
if (!isMountedRef.current || !pendingEvents?.length) return;
for (const event of pendingEvents) {
enqueueWithDebounce(event);
}
});
const cleanup = api.onFileChanged((event: FileChangedEvent) => {
enqueueWithDebounce(event);
});
return () => {
isMountedRef.current = false;
cleanup();
for (const timeout of debounceTimers.current.values()) {
clearTimeout(timeout);
}
debounceTimers.current.clear();
pendingByFolder.current.clear();
firstEventTime.current.clear();
};
}, []);
}

View file

@ -157,8 +157,10 @@ export function useInbox(
}) as InboxItem
);
const liveById = new Map(recentItems.map((v) => [v.id, v]));
let updated = prev.map((existing) => {
const liveItem = recentItems.find((v) => v.id === existing.id);
const liveItem = liveById.get(existing.id);
if (liveItem) {
return {
...existing,

View file

@ -1,4 +1,3 @@
import posthog from "posthog-js";
import type { ZodType } from "zod";
import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils";
import { AppError, AuthenticationError, AuthorizationError, NotFoundError } from "../error";
@ -234,18 +233,21 @@ class BaseApiService {
} catch (error) {
console.error("Request failed:", JSON.stringify(error));
if (!(error instanceof AuthenticationError)) {
try {
posthog.captureException(error, {
api_url: url,
api_method: options?.method ?? "GET",
...(error instanceof AppError && {
status_code: error.status,
status_text: error.statusText,
}),
import("posthog-js")
.then(({ default: posthog }) => {
posthog.captureException(error, {
api_url: url,
api_method: options?.method ?? "GET",
...(error instanceof AppError && {
status_code: error.status,
status_text: error.statusText,
}),
});
})
.catch(() => {
// PostHog is not available in the current environment
console.error("Failed to capture exception in PostHog");
});
} catch {
// PostHog capture failed — don't block the error flow
}
}
throw error;
}

View file

@ -6,6 +6,7 @@ import {
deleteDocumentRequest,
deleteDocumentResponse,
type GetDocumentByChunkRequest,
type GetDocumentChunksRequest,
type GetDocumentRequest,
type GetDocumentsRequest,
type GetDocumentsStatusRequest,
@ -13,6 +14,8 @@ import {
type GetSurfsenseDocsRequest,
getDocumentByChunkRequest,
getDocumentByChunkResponse,
getDocumentChunksRequest,
getDocumentChunksResponse,
getDocumentRequest,
getDocumentResponse,
getDocumentsRequest,
@ -37,6 +40,7 @@ import {
uploadDocumentRequest,
uploadDocumentResponse,
} from "@/contracts/types/document.types";
import { folderListResponse } from "@/contracts/types/folder.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
@ -295,23 +299,52 @@ class DocumentsApiService {
};
/**
* Get document by chunk ID (includes all chunks)
* Get document by chunk ID (includes a window of chunks around the cited one)
*/
getDocumentByChunk = async (request: GetDocumentByChunkRequest) => {
// Validate the request
const parsedRequest = getDocumentByChunkRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
// Format a user friendly error message
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const params = new URLSearchParams();
if (request.chunk_window != null) {
params.set("chunk_window", String(request.chunk_window));
}
const qs = params.toString();
const url = `/api/v1/documents/by-chunk/${request.chunk_id}${qs ? `?${qs}` : ""}`;
return baseApiService.get(url, getDocumentByChunkResponse);
};
/**
* Get paginated chunks for a document
*/
getDocumentChunks = async (request: GetDocumentChunksRequest) => {
const parsedRequest = getDocumentChunksRequest.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 params = new URLSearchParams({
page: String(parsedRequest.data.page),
page_size: String(parsedRequest.data.page_size),
});
if (parsedRequest.data.start_offset != null) {
params.set("start_offset", String(parsedRequest.data.start_offset));
}
return baseApiService.get(
`/api/v1/documents/by-chunk/${request.chunk_id}`,
getDocumentByChunkResponse
`/api/v1/documents/${parsedRequest.data.document_id}/chunks?${params}`,
getDocumentChunksResponse
);
};
@ -379,6 +412,54 @@ class DocumentsApiService {
});
};
listDocumentVersions = async (documentId: number) => {
return baseApiService.get(`/api/v1/documents/${documentId}/versions`);
};
getDocumentVersion = async (documentId: number, versionNumber: number) => {
return baseApiService.get(`/api/v1/documents/${documentId}/versions/${versionNumber}`);
};
restoreDocumentVersion = async (documentId: number, versionNumber: number) => {
return baseApiService.post(`/api/v1/documents/${documentId}/versions/${versionNumber}/restore`);
};
folderIndex = async (
searchSpaceId: number,
body: {
folder_path: string;
folder_name: string;
search_space_id: number;
exclude_patterns?: string[];
file_extensions?: string[];
root_folder_id?: number;
enable_summary?: boolean;
}
) => {
return baseApiService.post(`/api/v1/documents/folder-index`, undefined, { body });
};
folderIndexFiles = async (
searchSpaceId: number,
body: {
folder_path: string;
folder_name: string;
search_space_id: number;
target_file_paths: string[];
root_folder_id?: number | null;
enable_summary?: boolean;
}
) => {
return baseApiService.post(`/api/v1/documents/folder-index-files`, undefined, { body });
};
getWatchedFolders = async (searchSpaceId: number) => {
return baseApiService.get(
`/api/v1/documents/watched-folders?search_space_id=${searchSpaceId}`,
folderListResponse
);
};
/**
* Delete a document
*/

View file

@ -85,6 +85,10 @@ class FoldersApiService {
return baseApiService.delete(`/api/v1/folders/${folderId}`, folderDeleteResponse);
};
stopWatching = async (folderId: number) => {
return baseApiService.patch(`/api/v1/folders/${folderId}/watched`, undefined);
};
moveDocument = async (documentId: number, request: DocumentMoveRequest) => {
const parsed = documentMoveRequest.safeParse(request);
if (!parsed.success) {

View file

@ -147,7 +147,7 @@ class NewLLMConfigApiService {
};
/**
* Get the dynamic LLM model catalogue (sourced from OpenRouter API)
* Get the dynamic model catalogue (sourced from OpenRouter API)
*/
getModels = async () => {
return baseApiService.get(`/api/v1/models`, getModelListResponse);

View file

@ -1,13 +1,18 @@
"use client";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { QueryClientAtomProvider } from "jotai-tanstack-query/react";
import dynamic from "next/dynamic";
import { queryClient } from "./client";
const ReactQueryDevtools = dynamic(
() => import("@tanstack/react-query-devtools").then((m) => ({ default: m.ReactQueryDevtools })),
{ ssr: false }
);
export function ReactQueryClientProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientAtomProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
{process.env.NODE_ENV === "development" && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientAtomProvider>
);
}

View file

@ -376,13 +376,14 @@
"upload_documents": {
"title": "Upload Documents",
"subtitle": "Upload your files to make them searchable and accessible through AI-powered conversations.",
"file_size_limit": "Maximum file size: 50MB per file.",
"upload_limits": "Upload limit: {maxFiles} files, {maxSizeMB}MB total.",
"drop_files": "Drop files here",
"drag_drop": "Drag & drop files here",
"file_size_limit": "Maximum file size: 500MB per file",
"drop_files": "Drop files or folders here",
"drag_drop": "Drag & drop files or folders here",
"drag_drop_more": "Drop or browse to add more files",
"or_browse": "or click to browse",
"browse_files": "Browse Files",
"selected_files": "Selected Files ({count})",
"browse_folder": "Browse Folder",
"selected_files": "{count} selected {count, plural, one {file} other {files}}",
"total_size": "Total size",
"clear_all": "Clear all",
"uploading_files": "Uploading files",
@ -393,14 +394,9 @@
"upload_error": "Upload Error",
"upload_error_desc": "Error uploading files",
"supported_file_types": "Supported File Types",
"file_types_desc": "These file types are supported based on your current ETL service configuration.",
"max_files_exceeded": "File Limit Exceeded",
"max_files_exceeded_desc": "You can upload a maximum of {max} files at a time.",
"max_size_exceeded": "Size Limit Exceeded",
"max_size_exceeded_desc": "Total file size cannot exceed {max}MB.",
"file_limit_reached": "Maximum Files Reached",
"file_limit_reached_desc": "Remove some files to add more (max {max} files).",
"remaining_capacity": "{files} files remaining • {sizeMB}MB available"
"file_too_large": "File Too Large",
"file_too_large_desc": "\"{name}\" exceeds the {maxMB}MB per-file limit.",
"no_supported_files_in_folder": "No supported file types found in the selected folder."
},
"add_webpage": {
"title": "Add Webpages for Crawling",
@ -737,7 +733,7 @@
"nav_general": "General",
"nav_general_desc": "Name, description & basic info",
"nav_agent_configs": "Agent Configs",
"nav_agent_configs_desc": "LLM models with prompts & citations",
"nav_agent_configs_desc": "Models with prompts & citations",
"nav_role_assignments": "Role Assignments",
"nav_role_assignments_desc": "Assign configs to agent roles",
"nav_image_models": "Image Models",

View file

@ -376,13 +376,14 @@
"upload_documents": {
"title": "Subir documentos",
"subtitle": "Sube tus archivos para hacerlos buscables y accesibles a través de conversaciones con IA.",
"file_size_limit": "Tamaño máximo de archivo: 50 MB por archivo.",
"upload_limits": "Límite de subida: {maxFiles} archivos, {maxSizeMB} MB en total.",
"drop_files": "Suelta los archivos aquí",
"drag_drop": "Arrastra y suelta archivos aquí",
"file_size_limit": "Tamaño máximo de archivo: 500 MB por archivo",
"drop_files": "Suelta archivos o carpetas aquí",
"drag_drop": "Arrastra y suelta archivos o carpetas aquí",
"drag_drop_more": "Suelta o explora para agregar más archivos",
"or_browse": "o haz clic para explorar",
"browse_files": "Explorar archivos",
"selected_files": "Archivos seleccionados ({count})",
"browse_folder": "Explorar carpeta",
"selected_files": "{count} {count, plural, one {archivo seleccionado} other {archivos seleccionados}}",
"total_size": "Tamaño total",
"clear_all": "Limpiar todo",
"uploading_files": "Subiendo archivos",
@ -393,14 +394,9 @@
"upload_error": "Error de subida",
"upload_error_desc": "Error al subir archivos",
"supported_file_types": "Tipos de archivo soportados",
"file_types_desc": "Estos tipos de archivo son soportados según la configuración actual de tu servicio ETL.",
"max_files_exceeded": "Límite de archivos excedido",
"max_files_exceeded_desc": "Puedes subir un máximo de {max} archivos a la vez.",
"max_size_exceeded": "Límite de tamaño excedido",
"max_size_exceeded_desc": "El tamaño total de los archivos no puede exceder {max} MB.",
"file_limit_reached": "Máximo de archivos alcanzado",
"file_limit_reached_desc": "Elimina algunos archivos para agregar más (máximo {max} archivos).",
"remaining_capacity": "{files} archivos restantes • {sizeMB} MB disponibles"
"file_too_large": "Archivo demasiado grande",
"file_too_large_desc": "\"{name}\" excede el límite de {maxMB} MB por archivo.",
"no_supported_files_in_folder": "No se encontraron tipos de archivo compatibles en la carpeta seleccionada."
},
"add_webpage": {
"title": "Agregar páginas web para rastreo",

View file

@ -376,13 +376,14 @@
"upload_documents": {
"title": "दस्तावेज़ अपलोड करें",
"subtitle": "AI-संचालित बातचीत के माध्यम से अपनी फ़ाइलों को खोजने योग्य और सुलभ बनाने के लिए अपलोड करें।",
"file_size_limit": "अधिकतम फ़ाइल आकार: प्रति फ़ाइल 50MB",
"upload_limits": "अपलोड सीमा: {maxFiles} फ़ाइलें, कुल {maxSizeMB}MB।",
"drop_files": "फ़ाइलें यहां छोड़ें",
"drag_drop": "फ़ाइलें यहां खींचें और छोड़ें",
"file_size_limit": "अधिकतम फ़ाइल आकार: प्रति फ़ाइल 500MB",
"drop_files": "फ़ाइलें या फ़ोल्डर यहां छोड़ें",
"drag_drop": "फ़ाइलें या फ़ोल्डर यहां खींचें और छोड़ें",
"drag_drop_more": "और फ़ाइलें जोड़ने के लिए छोड़ें या ब्राउज़ करें",
"or_browse": "या ब्राउज़ करने के लिए क्लिक करें",
"browse_files": "फ़ाइलें ब्राउज़ करें",
"selected_files": "चयनित फ़ाइलें ({count})",
"browse_folder": "फ़ोल्डर ब्राउज़ करें",
"selected_files": "{count} चयनित {count, plural, one {फ़ाइल} other {फ़ाइलें}}",
"total_size": "कुल आकार",
"clear_all": "सभी साफ करें",
"uploading_files": "फ़ाइलें अपलोड हो रही हैं",
@ -393,14 +394,9 @@
"upload_error": "अपलोड त्रुटि",
"upload_error_desc": "फ़ाइलें अपलोड करने में त्रुटि",
"supported_file_types": "समर्थित फ़ाइल प्रकार",
"file_types_desc": "ये फ़ाइल प्रकार आपकी वर्तमान ETL सेवा कॉन्फ़िगरेशन के आधार पर समर्थित हैं।",
"max_files_exceeded": "फ़ाइल सीमा पार हो गई",
"max_files_exceeded_desc": "आप एक बार में अधिकतम {max} फ़ाइलें अपलोड कर सकते हैं।",
"max_size_exceeded": "आकार सीमा पार हो गई",
"max_size_exceeded_desc": "कुल फ़ाइल आकार {max}MB से अधिक नहीं हो सकता।",
"file_limit_reached": "अधिकतम फ़ाइलें पहुंच गई",
"file_limit_reached_desc": "और जोड़ने के लिए कुछ फ़ाइलें हटाएं (अधिकतम {max} फ़ाइलें)।",
"remaining_capacity": "{files} फ़ाइलें शेष • {sizeMB}MB उपलब्ध"
"file_too_large": "फ़ाइल बहुत बड़ी है",
"file_too_large_desc": "\"{name}\" प्रति फ़ाइल {maxMB}MB की सीमा से अधिक है।",
"no_supported_files_in_folder": "चयनित फ़ोल्डर में कोई समर्थित फ़ाइल प्रकार नहीं मिला।"
},
"add_webpage": {
"title": "क्रॉलिंग के लिए वेबपेज जोड़ें",

View file

@ -376,13 +376,14 @@
"upload_documents": {
"title": "Enviar documentos",
"subtitle": "Envie seus arquivos para torná-los pesquisáveis e acessíveis através de conversas com IA.",
"file_size_limit": "Tamanho máximo do arquivo: 50 MB por arquivo.",
"upload_limits": "Limite de envio: {maxFiles} arquivos, {maxSizeMB} MB no total.",
"drop_files": "Solte os arquivos aqui",
"drag_drop": "Arraste e solte arquivos aqui",
"file_size_limit": "Tamanho máximo do arquivo: 500 MB por arquivo",
"drop_files": "Solte arquivos ou pastas aqui",
"drag_drop": "Arraste e solte arquivos ou pastas aqui",
"drag_drop_more": "Solte ou navegue para adicionar mais arquivos",
"or_browse": "ou clique para navegar",
"browse_files": "Navegar arquivos",
"selected_files": "Arquivos selecionados ({count})",
"browse_folder": "Navegar pasta",
"selected_files": "{count} {count, plural, one {arquivo selecionado} other {arquivos selecionados}}",
"total_size": "Tamanho total",
"clear_all": "Limpar tudo",
"uploading_files": "Enviando arquivos",
@ -393,14 +394,9 @@
"upload_error": "Erro no envio",
"upload_error_desc": "Erro ao enviar arquivos",
"supported_file_types": "Tipos de arquivo suportados",
"file_types_desc": "Estes tipos de arquivo são suportados com base na configuração atual do seu serviço ETL.",
"max_files_exceeded": "Limite de arquivos excedido",
"max_files_exceeded_desc": "Você pode enviar no máximo {max} arquivos de uma vez.",
"max_size_exceeded": "Limite de tamanho excedido",
"max_size_exceeded_desc": "O tamanho total dos arquivos não pode exceder {max} MB.",
"file_limit_reached": "Máximo de arquivos atingido",
"file_limit_reached_desc": "Remova alguns arquivos para adicionar mais (máximo {max} arquivos).",
"remaining_capacity": "{files} arquivos restantes • {sizeMB} MB disponíveis"
"file_too_large": "Arquivo muito grande",
"file_too_large_desc": "\"{name}\" excede o limite de {maxMB} MB por arquivo.",
"no_supported_files_in_folder": "Nenhum tipo de arquivo suportado encontrado na pasta selecionada."
},
"add_webpage": {
"title": "Adicionar páginas web para rastreamento",

View file

@ -360,13 +360,14 @@
"upload_documents": {
"title": "上传文档",
"subtitle": "上传您的文件,使其可通过 AI 对话进行搜索和访问。",
"file_size_limit": "最大文件大小:每个文件 50MB",
"upload_limits": "上传限制:最多 {maxFiles} 个文件,总大小不超过 {maxSizeMB}MB。",
"drop_files": "放下文件到这里",
"drag_drop": "拖放文件到这里",
"file_size_limit": "最大文件大小:每个文件 500MB",
"drop_files": "将文件或文件夹拖放到此处",
"drag_drop": "拖放文件或文件夹到这里",
"drag_drop_more": "拖放或浏览以添加更多文件",
"or_browse": "或点击浏览",
"browse_files": "浏览文件",
"selected_files": "已选择的文件 ({count})",
"browse_folder": "浏览文件夹",
"selected_files": "已选择 {count} 个文件",
"total_size": "总大小",
"clear_all": "全部清除",
"uploading_files": "正在上传文件...",
@ -377,14 +378,9 @@
"upload_error": "上传错误",
"upload_error_desc": "上传文件时出错",
"supported_file_types": "支持的文件类型",
"file_types_desc": "根据您当前的 ETL 服务配置支持这些文件类型。",
"max_files_exceeded": "超过文件数量限制",
"max_files_exceeded_desc": "一次最多只能上传 {max} 个文件。",
"max_size_exceeded": "超过文件大小限制",
"max_size_exceeded_desc": "文件总大小不能超过 {max}MB。",
"file_limit_reached": "已达到最大文件数量",
"file_limit_reached_desc": "移除一些文件以添加更多(最多 {max} 个文件)。",
"remaining_capacity": "剩余 {files} 个文件名额 • 可用 {sizeMB}MB"
"file_too_large": "文件过大",
"file_too_large_desc": "\"{name}\" 超过了每个文件 {maxMB}MB 的限制。",
"no_supported_files_in_folder": "所选文件夹中没有找到支持的文件类型。"
},
"add_webpage": {
"title": "添加网页爬取",

View file

@ -24,6 +24,16 @@ const nextConfig: NextConfig = {
},
],
},
experimental: {
optimizePackageImports: [
"lucide-react",
"@tabler/icons-react",
"date-fns",
"@assistant-ui/react",
"@assistant-ui/react-markdown",
"motion",
],
},
// Turbopack config (used during `next dev --turbopack`)
turbopack: {
rules: {

View file

@ -1,5 +1,39 @@
import type { PostHog } from "posthog-js";
interface WatchedFolderConfig {
path: string;
name: string;
excludePatterns: string[];
fileExtensions: string[] | null;
rootFolderId: number | null;
searchSpaceId: number;
active: boolean;
}
interface FolderSyncFileChangedEvent {
id: string;
rootFolderId: number | null;
searchSpaceId: number;
folderPath: string;
folderName: string;
relativePath: string;
fullPath: string;
action: "add" | "change" | "unlink";
timestamp: number;
}
interface FolderSyncWatcherReadyEvent {
rootFolderId: number | null;
folderPath: string;
}
interface LocalFileData {
name: string;
data: ArrayBuffer;
mimeType: string;
size: number;
}
interface ElectronAPI {
versions: {
electron: string;
@ -28,6 +62,22 @@ interface ElectronAPI {
dismissSuggestion: () => Promise<void>;
setAutocompleteEnabled: (enabled: boolean) => Promise<void>;
getAutocompleteEnabled: () => Promise<boolean>;
// Folder sync
selectFolder: () => Promise<string | null>;
addWatchedFolder: (config: WatchedFolderConfig) => Promise<WatchedFolderConfig[]>;
removeWatchedFolder: (folderPath: string) => Promise<WatchedFolderConfig[]>;
getWatchedFolders: () => Promise<WatchedFolderConfig[]>;
getWatcherStatus: () => Promise<{ path: string; active: boolean; watching: boolean }[]>;
onFileChanged: (callback: (data: FolderSyncFileChangedEvent) => void) => () => void;
onWatcherReady: (callback: (data: FolderSyncWatcherReadyEvent) => void) => () => void;
pauseWatcher: () => Promise<void>;
resumeWatcher: () => Promise<void>;
signalRendererReady: () => Promise<void>;
getPendingFileEvents: () => Promise<FolderSyncFileChangedEvent[]>;
acknowledgeFileEvents: (eventIds: string[]) => Promise<{ acknowledged: number }>;
// Browse files/folders via native dialogs
browseFiles: () => Promise<string[] | null>;
readLocalFiles: (paths: string[]) => Promise<LocalFileData[]>;
}
declare global {