Merge branch 'dev' into fix/debounce-logs-message-filter

This commit is contained in:
Soham Bhattacharjee 2026-04-08 05:08:25 +05:30 committed by GitHub
commit a323f2e2c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
279 changed files with 20333 additions and 4970 deletions

View file

@ -1,8 +1,8 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils";
import { trackLoginSuccess } from "@/lib/posthog/events";
@ -26,55 +26,59 @@ 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);
useEffect(() => {
// 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");
const run = async () => {
const params = new URLSearchParams(window.location.search);
const token = params.get(tokenParamName);
const refreshToken = params.get("refresh_token");
if (token) {
try {
// Track login success for OAuth flows (e.g., Google)
// Local login already tracks success before redirecting here
const alreadyTracked = sessionStorage.getItem("login_success_tracked");
if (!alreadyTracked) {
// This is an OAuth flow (Google login) - track success
trackLoginSuccess("google");
if (token) {
try {
const alreadyTracked = sessionStorage.getItem("login_success_tracked");
if (!alreadyTracked) {
trackLoginSuccess("google");
}
sessionStorage.removeItem("login_success_tracked");
localStorage.setItem(storageKey, token);
setBearerToken(token);
if (refreshToken) {
setRefreshToken(refreshToken);
}
// Auto-set active search space in desktop if not already set
if (window.electronAPI?.getActiveSearchSpace) {
try {
const stored = await window.electronAPI.getActiveSearchSpace();
if (!stored) {
const spaces = await searchSpacesApiService.getSearchSpaces();
if (spaces?.length) {
await window.electronAPI.setActiveSearchSpace?.(String(spaces[0].id));
}
}
} catch {
// non-critical
}
}
const savedRedirectPath = getAndClearRedirectPath();
const finalRedirectPath = savedRedirectPath || redirectPath;
window.location.href = finalRedirectPath;
} catch (error) {
console.error("Error storing token in localStorage:", error);
window.location.href = redirectPath;
}
// Clear the flag for future logins
sessionStorage.removeItem("login_success_tracked");
// Store access token in localStorage using both methods for compatibility
localStorage.setItem(storageKey, token);
setBearerToken(token);
// Store refresh token if provided
if (refreshToken) {
setRefreshToken(refreshToken);
}
// Check if there's a saved redirect path from before the auth flow
const savedRedirectPath = getAndClearRedirectPath();
// Use the saved path if available, otherwise use the default redirectPath
const finalRedirectPath = savedRedirectPath || redirectPath;
// Redirect to the appropriate path
window.location.href = finalRedirectPath;
} catch (error) {
console.error("Error storing token in localStorage:", error);
// Even if there's an error, try to redirect to the default path
window.location.href = redirectPath;
}
}
}, [searchParams, tokenParamName, storageKey, redirectPath]);
};
run();
}, [tokenParamName, storageKey, redirectPath]);
// Return null - the global provider handles the loading UI
return null;

View file

@ -16,7 +16,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { logout } from "@/lib/auth-utils";
import { getLoginPath, logout } from "@/lib/auth-utils";
import { resetUser, trackLogout } from "@/lib/posthog/events";
export function UserDropdown({
@ -35,21 +35,24 @@ export function UserDropdown({
if (isLoggingOut) return;
setIsLoggingOut(true);
try {
// Track logout event and reset PostHog identity
trackLogout();
resetUser();
// Revoke refresh token on server and clear all tokens from localStorage
await logout();
router.push("/");
router.refresh();
if (typeof window !== "undefined") {
window.location.href = getLoginPath();
}
} catch (error) {
console.error("Error during logout:", error);
// Even if there's an error, try to clear tokens and redirect
await logout();
router.push("/");
router.refresh();
if (typeof window !== "undefined") {
window.location.href = getLoginPath();
}
}
};

View file

@ -87,8 +87,14 @@ import {
} from "@/components/ui/drawer";
import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
// Captured once at module load — survives client-side navigations that strip the query param.
const IS_QUICK_ASSIST_WINDOW =
typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("quickAssist") === "true";
// Dynamically import video presentation tool to avoid loading Babel and Remotion in main bundle
const GenerateVideoPresentationToolUI = dynamic(
() =>
@ -463,16 +469,9 @@ export const AssistantMessage: FC = () => {
const AssistantActionBar: FC = () => {
const isLast = useAuiState((s) => s.message.isLast);
const aui = useAui();
const [quickAskMode, setQuickAskMode] = useState("");
const api = useElectronAPI();
useEffect(() => {
if (!isLast || !window.electronAPI?.getQuickAskMode) return;
window.electronAPI.getQuickAskMode().then((mode) => {
if (mode) setQuickAskMode(mode);
});
}, [isLast]);
const isTransform = isLast && !!window.electronAPI?.replaceText && quickAskMode === "transform";
const isQuickAssist = !!api?.replaceText && IS_QUICK_ASSIST_WINDOW;
return (
<ActionBarPrimitive.Root
@ -482,7 +481,7 @@ const AssistantActionBar: FC = () => {
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground md:data-floating:absolute md:data-floating:rounded-md md:data-floating:p-1 [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
>
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy">
<TooltipIconButton tooltip="Copy to clipboard">
<AuiIf condition={({ message }) => message.isCopied}>
<CheckIcon />
</AuiIf>
@ -492,29 +491,27 @@ const AssistantActionBar: FC = () => {
</TooltipIconButton>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.ExportMarkdown asChild>
<TooltipIconButton tooltip="Download">
<TooltipIconButton tooltip="Download as Markdown">
<DownloadIcon />
</TooltipIconButton>
</ActionBarPrimitive.ExportMarkdown>
{isLast && (
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Refresh">
<TooltipIconButton tooltip="Regenerate response">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
)}
{isTransform && (
<button
type="button"
{isQuickAssist && (
<TooltipIconButton
tooltip="Paste back into source app"
onClick={() => {
const text = aui.message().getCopyText();
window.electronAPI?.replaceText(text);
api?.replaceText(text);
}}
className="ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<ClipboardPaste className="size-3.5" />
Paste back
</button>
<ClipboardPaste />
</TooltipIconButton>
)}
</ActionBarPrimitive.Root>
);

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 } =
@ -218,7 +216,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
onPointerDownOutside={(e) => {
if (pickerOpen) e.preventDefault();
}}
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none"
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-foreground/10 [&>button>svg]:size-5 select-none"
>
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
@ -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

@ -144,18 +144,14 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
type="button"
onClick={handleFormSubmit}
disabled={isSubmitting}
className="text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
className="relative text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
>
{isSubmitting ? (
<>
<Spinner size="sm" className="mr-2" />
Connecting
</>
) : connectorType === "MCP_CONNECTOR" ? (
"Connect"
) : (
`Connect ${getConnectorTypeDisplay(connectorType)}`
)}
<span className={isSubmitting ? "opacity-0" : ""}>
{connectorType === "MCP_CONNECTOR"
? "Connect"
: `Connect ${getConnectorTypeDisplay(connectorType)}`}
</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
</div>
</div>

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";
@ -371,16 +369,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
size="sm"
onClick={handleDisconnectConfirm}
disabled={isDisconnecting}
className="text-xs sm:text-sm flex-1 sm:flex-initial h-10 sm:h-auto py-2 sm:py-2"
className="relative text-xs sm:text-sm flex-1 sm:flex-initial h-10 sm:h-auto py-2 sm:py-2"
>
{isDisconnecting ? (
<>
<Spinner size="sm" className="mr-2" />
Disconnecting
</>
) : (
"Confirm Disconnect"
)}
<span className={isDisconnecting ? "opacity-0" : ""}>Confirm Disconnect</span>
{isDisconnecting && <Spinner size="sm" className="absolute" />}
</Button>
<Button
variant="ghost"
@ -417,16 +409,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
<Button
onClick={onSave}
disabled={isSaving || isDisconnecting}
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
className="relative text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
>
{isSaving ? (
<>
<Spinner size="sm" className="mr-2" />
Saving
</>
) : (
"Save Changes"
)}
<span className={isSaving ? "opacity-0" : ""}>Save Changes</span>
{isSaving && <Spinner size="sm" className="absolute" />}
</Button>
)}
</div>

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

@ -1,6 +1,6 @@
"use client";
import { Cable } from "lucide-react";
import { Search, Unplug } from "lucide-react";
import type { FC } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { Button } from "@/components/ui/button";
@ -134,9 +134,17 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
const hasActiveConnectors =
filteredOAuthConnectorTypes.length > 0 || filteredNonOAuthConnectors.length > 0;
const hasFilteredResults = hasActiveConnectors || standaloneDocuments.length > 0;
return (
<TabsContent value="active" className="m-0">
{hasSources ? (
{hasSources && !hasFilteredResults && searchQuery ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Search className="size-8 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">No connectors found</p>
<p className="text-xs text-muted-foreground/60 mt-1">Try a different search term</p>
</div>
) : hasSources ? (
<div className="space-y-6">
{/* Active Connectors Section */}
{hasActiveConnectors && (
@ -302,7 +310,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
) : (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
<Cable className="size-8 text-muted-foreground" />
<Unplug className="size-8 text-muted-foreground" />
</div>
<h4 className="text-lg font-semibold">No active sources</h4>
<p className="text-sm text-muted-foreground mt-1 max-w-[280px]">

View file

@ -1,8 +1,10 @@
"use client";
import { Search } from "lucide-react";
import type { FC } from "react";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { usePlatform } from "@/hooks/use-platform";
import { isSelfHosted } from "@/lib/env-config";
import { ConnectorCard } from "../components/connector-card";
import {
@ -74,31 +76,27 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
onManage,
onViewAccountsList,
}) => {
// Check if self-hosted mode (for showing self-hosted only connectors)
const selfHosted = isSelfHosted();
const { isDesktop } = usePlatform();
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
@ -290,6 +288,18 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
moreIntegrationsOther.length > 0 ||
moreIntegrationsCrawlers.length > 0;
const hasAnyResults = hasDocumentFileConnectors || hasMoreIntegrations;
if (!hasAnyResults && searchQuery) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Search className="size-8 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">No connectors found</p>
<p className="text-xs text-muted-foreground/60 mt-1">Try a different search term</p>
</div>
);
}
return (
<div className="space-y-8">
{/* Document/Files Connectors */}

View file

@ -173,9 +173,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
<Plus className="size-3 text-primary" />
)}
</div>
<span className="text-xs sm:text-sm font-medium">
{isConnecting ? "Connecting" : buttonText}
</span>
<span className="text-xs sm:text-sm font-medium">{buttonText}</span>
</button>
</div>
</div>

View file

@ -335,16 +335,10 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
<Button
onClick={handleSubmit}
disabled={isSubmitting || isFetchingPlaylist || videoTags.length === 0}
className="text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
className="relative text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
>
{isSubmitting ? (
<>
<Spinner size="sm" className="mr-2" />
{t("processing")}
</>
) : (
t("submit")
)}
<span className={isSubmitting ? "opacity-0" : ""}>{t("submit")}</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
</div>
</div>

View file

@ -125,38 +125,33 @@ 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(520px,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-5 sm:[&>button]:top-8 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-foreground/10 [&>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-6 sm:pt-8 pb-10">
<div className="flex items-center gap-2 mb-1 pr-8 sm:pr-0">
<h2 className="text-xl sm:text-3xl font-semibold tracking-tight">Upload Documents</h2>
</div>
<p className="text-xs sm:text-base text-muted-foreground/80 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 +174,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

@ -3,6 +3,7 @@
import type { ImageMessagePartComponent } from "@assistant-ui/react";
import { cva, type VariantProps } from "class-variance-authority";
import { ImageIcon, ImageOffIcon } from "lucide-react";
import NextImage from "next/image";
import { memo, type PropsWithChildren, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
@ -86,8 +87,8 @@ function ImagePreview({
>
<ImageOffIcon className="size-8 text-muted-foreground" />
</div>
) : (
// biome-ignore lint/performance/noImgElement: intentional for dynamic external URLs
) : isDataOrBlobUrl(src) ? (
// biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
<img
ref={imgRef}
src={src}
@ -103,6 +104,40 @@ function ImagePreview({
}}
{...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}
// />
<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") {
{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

@ -24,6 +24,7 @@ export interface MentionedDocument {
export interface InlineMentionEditorRef {
focus: () => void;
clear: () => void;
setText: (text: string) => void;
getText: () => string;
getMentionedDocuments: () => MentionedDocument[];
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
@ -397,6 +398,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
}
}, []);
// Replace editor content with plain text and place cursor at end
const setText = useCallback(
(text: string) => {
if (!editorRef.current) return;
editorRef.current.innerText = text;
const empty = text.length === 0;
setIsEmpty(empty);
onChange?.(text, Array.from(mentionedDocs.values()));
focusAtEnd();
},
[focusAtEnd, onChange, mentionedDocs]
);
const setDocumentChipStatus = useCallback(
(
docId: number,
@ -469,6 +483,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
useImperativeHandle(ref, () => ({
focus: () => editorRef.current?.focus(),
clear,
setText,
getText,
getMentionedDocuments,
insertDocumentChip,

View file

@ -9,7 +9,7 @@ import {
TrashIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { memo, useCallback, useEffect, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -224,6 +224,11 @@ const ThreadListItemComponent = memo(function ThreadListItemComponent({
onUnarchive,
onDelete,
}: ThreadListItemComponentProps) {
const relativeTime = useMemo(
() => formatRelativeTime(new Date(thread.updatedAt)),
[thread.updatedAt]
);
return (
<button
type="button"
@ -236,9 +241,7 @@ const ThreadListItemComponent = memo(function ThreadListItemComponent({
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{thread.title || "New Chat"}</p>
<p className="truncate text-xs text-muted-foreground">
{formatRelativeTime(new Date(thread.updatedAt))}
</p>
<p className="truncate text-xs text-muted-foreground">{relativeTime}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>

View file

@ -15,6 +15,7 @@ import {
ChevronDown,
ChevronUp,
Clipboard,
Dot,
Globe,
Plus,
Settings2,
@ -88,17 +89,10 @@ import type { Document } from "@/contracts/types/document.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsSync } from "@/hooks/use-comments-sync";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
/** Placeholder texts that cycle in new chats when input is empty */
const CYCLING_PLACEHOLDERS = [
"Ask SurfSense anything or @mention docs",
"Generate a podcast from my vacation ideas in Notion",
"Sum up last week's meeting notes from Drive in a bulleted list",
"Give me a brief overview of the most urgent tickets in Jira and Linear",
"Briefly, what are today's top ten important emails and calendar events?",
"Check if this week's Slack messages reference any GitHub issues",
];
const COMPOSER_PLACEHOLDER = "Ask anything · Type / for prompts · Type @ to mention docs";
export const Thread: FC = () => {
return <ThreadContent />;
@ -361,45 +355,23 @@ const Composer: FC = () => {
};
}, []);
const electronAPI = useElectronAPI();
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
const clipboardLoadedRef = useRef(false);
useEffect(() => {
if (!window.electronAPI || clipboardLoadedRef.current) return;
if (!electronAPI || clipboardLoadedRef.current) return;
clipboardLoadedRef.current = true;
window.electronAPI.getQuickAskText().then((text) => {
electronAPI.getQuickAskText().then((text) => {
if (text) {
setClipboardInitialText(text);
setShowPromptPicker(true);
}
});
}, []);
}, [electronAPI]);
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
// Cycling placeholder state - only cycles in new chats
const [placeholderIndex, setPlaceholderIndex] = useState(0);
// Cycle through placeholders every 4 seconds when thread is empty (new chat)
useEffect(() => {
// Only cycle when thread is empty (new chat)
if (!isThreadEmpty) {
// Reset to first placeholder when chat becomes active
setPlaceholderIndex(0);
return;
}
const intervalId = setInterval(() => {
setPlaceholderIndex((prev) => (prev + 1) % CYCLING_PLACEHOLDERS.length);
}, 6000);
return () => clearInterval(intervalId);
}, [isThreadEmpty]);
// Compute current placeholder - only cycle in new chats
const currentPlaceholder = isThreadEmpty
? CYCLING_PLACEHOLDERS[placeholderIndex]
: CYCLING_PLACEHOLDERS[0];
const currentPlaceholder = COMPOSER_PLACEHOLDER;
// Live collaboration state
const { data: currentUser } = useAtomValue(currentUserAtom);
@ -503,34 +475,28 @@ const Composer: FC = () => {
: userText
? `${action.prompt}\n\n${userText}`
: action.prompt;
editorRef.current?.setText(finalPrompt);
aui.composer().setText(finalPrompt);
aui.composer().send();
editorRef.current?.clear();
setShowPromptPicker(false);
setActionQuery("");
setMentionedDocuments([]);
setSidebarDocs([]);
},
[actionQuery, aui, setMentionedDocuments, setSidebarDocs]
[actionQuery, aui]
);
const handleQuickAskSelect = useCallback(
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
if (!clipboardInitialText) return;
window.electronAPI?.setQuickAskMode(action.mode);
electronAPI?.setQuickAskMode(action.mode);
const finalPrompt = action.prompt.includes("{selection}")
? action.prompt.replace("{selection}", () => clipboardInitialText)
: `${action.prompt}\n\n${clipboardInitialText}`;
editorRef.current?.setText(finalPrompt);
aui.composer().setText(finalPrompt);
aui.composer().send();
editorRef.current?.clear();
setShowPromptPicker(false);
setActionQuery("");
setClipboardInitialText(undefined);
setMentionedDocuments([]);
setSidebarDocs([]);
},
[clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs]
[clipboardInitialText, electronAPI, aui]
);
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape)
@ -816,12 +782,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 +811,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 +824,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 +935,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 +967,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 +1041,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 +1056,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 +1093,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 +1124,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

@ -26,7 +26,8 @@ export const ToolFallback: ToolCallMessagePartComponent = ({
);
const serializedResult = useMemo(
() => (result !== undefined && typeof result !== "string" ? JSON.stringify(result, null, 2) : null),
() =>
result !== undefined && typeof result !== "string" ? JSON.stringify(result, null, 2) : null,
[result]
);

View file

@ -1,6 +1,6 @@
"use client";
import { ArrowUp, Send, X } from "lucide-react";
import { ArrowUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
@ -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;
@ -306,7 +307,6 @@ export function CommentComposer({
onClick={onCancel}
disabled={isSubmitting}
>
<X className="mr-1 size-4" />
Cancel
</Button>
)}
@ -317,14 +317,7 @@ export function CommentComposer({
disabled={!canSubmit}
className={cn(!canSubmit && "opacity-50", compact && "size-8 shrink-0 rounded-full")}
>
{compact ? (
<ArrowUp className="size-4" />
) : (
<>
<Send className="mr-1 size-4" />
{submitLabel}
</>
)}
{compact ? <ArrowUp className="size-4" /> : submitLabel}
</Button>
</div>
</div>

View file

@ -1,6 +1,6 @@
"use client";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { MoreHorizontal, PenLine, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -21,15 +21,15 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
<Button
variant="ghost"
size="icon"
className="size-7 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity"
className="size-7 text-muted-foreground opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity"
>
<MoreHorizontal className="size-4 text-muted-foreground" />
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canEdit && (
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 size-4" />
<PenLine className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}

View file

@ -198,7 +198,7 @@ export function CommentItem({
<CommentComposer
members={members}
membersLoading={membersLoading}
placeholder="Edit your comment..."
placeholder="Edit your comment"
submitLabel="Save"
isSubmitting={isSubmitting}
onSubmit={handleEditSubmit}

View file

@ -0,0 +1,163 @@
"use client";
import { RotateCcw } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Accelerator <-> display helpers
// ---------------------------------------------------------------------------
export function keyEventToAccelerator(e: React.KeyboardEvent): string | null {
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push("CommandOrControl");
if (e.altKey) parts.push("Alt");
if (e.shiftKey) parts.push("Shift");
const key = e.key;
if (["Control", "Meta", "Alt", "Shift"].includes(key)) return null;
if (key === " ") parts.push("Space");
else if (key.length === 1) parts.push(key.toUpperCase());
else parts.push(key);
if (parts.length < 2) return null;
return parts.join("+");
}
export function acceleratorToDisplay(accel: string): string[] {
if (!accel) return [];
return accel.split("+").map((part) => {
if (part === "CommandOrControl") return "Ctrl";
if (part === "Space") return "Space";
return part;
});
}
export const DEFAULT_SHORTCUTS = {
generalAssist: "CommandOrControl+Shift+S",
quickAsk: "CommandOrControl+Alt+S",
autocomplete: "CommandOrControl+Shift+Space",
};
// ---------------------------------------------------------------------------
// Kbd pill component
// ---------------------------------------------------------------------------
export function Kbd({ keys, className }: { keys: string[]; className?: string }) {
return (
<span className={cn("inline-flex items-center gap-0.5", className)}>
{keys.map((key, i) => (
<kbd
key={`${key}-${i}`}
className={cn(
"inline-flex h-6 min-w-6 items-center justify-center rounded border bg-muted px-1 font-mono text-[11px] font-medium text-muted-foreground",
key.length > 3 && "px-1.5"
)}
>
{key}
</kbd>
))}
</span>
);
}
// ---------------------------------------------------------------------------
// Shortcut recorder component
// ---------------------------------------------------------------------------
export function ShortcutRecorder({
value,
onChange,
onReset,
defaultValue,
label,
description,
icon: Icon,
}: {
value: string;
onChange: (accelerator: string) => void;
onReset: () => void;
defaultValue: string;
label: string;
description: string;
icon: React.ElementType;
}) {
const [recording, setRecording] = useState(false);
const inputRef = useRef<HTMLButtonElement>(null);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!recording) return;
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
setRecording(false);
return;
}
const accel = keyEventToAccelerator(e);
if (accel) {
onChange(accel);
setRecording(false);
}
},
[recording, onChange]
);
const displayKeys = acceleratorToDisplay(value);
const isDefault = value === defaultValue;
return (
<div className="group flex items-center gap-3 rounded-lg border border-border/60 bg-card px-3 py-2.5 transition-colors hover:border-border">
{/* Icon */}
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Icon className="size-4" />
</div>
{/* Label + description */}
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium leading-none">{label}</p>
<p className="mt-1 text-[11px] leading-snug text-muted-foreground">{description}</p>
</div>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
{!isDefault && (
<Button
variant="ghost"
size="icon"
className="size-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={onReset}
title="Reset to default"
>
<RotateCcw className="size-3" />
</Button>
)}
<button
ref={inputRef}
type="button"
onClick={() => setRecording(true)}
onKeyDown={handleKeyDown}
onBlur={() => setRecording(false)}
className={cn(
"flex h-7 items-center gap-0.5 rounded-md border px-2 transition-all focus:outline-none",
recording
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "border-input bg-muted/40 hover:bg-muted"
)}
>
{recording ? (
<span className="text-[11px] text-primary animate-pulse whitespace-nowrap">
Press keys
</span>
) : (
<Kbd keys={displayKeys} />
)}
</button>
</div>
</div>
);
}

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) {
@ -102,7 +106,9 @@ export const DocumentNode = React.memo(function DocumentNode({
const isProcessing = statusState === "pending" || statusState === "processing";
const [dropdownOpen, setDropdownOpen] = useState(false);
const [exporting, setExporting] = useState<string | null>(null);
const [titleTooltipOpen, setTitleTooltipOpen] = useState(false);
const rowRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLSpanElement>(null);
const handleExport = useCallback(
(format: string) => {
@ -114,6 +120,14 @@ export const DocumentNode = React.memo(function DocumentNode({
[doc, onExport]
);
const handleTitleTooltipOpenChange = useCallback((open: boolean) => {
if (open && titleRef.current) {
setTitleTooltipOpen(titleRef.current.scrollWidth > titleRef.current.clientWidth);
} else {
setTitleTooltipOpen(false);
}
}, []);
const attachRef = useCallback(
(node: HTMLDivElement | null) => {
(rowRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
@ -193,14 +207,32 @@ export const DocumentNode = React.memo(function DocumentNode({
);
})()}
<span className="flex-1 min-w-0 truncate">{doc.title}</span>
<Tooltip
delayDuration={600}
open={titleTooltipOpen}
onOpenChange={handleTitleTooltipOpenChange}
>
<TooltipTrigger asChild>
<span ref={titleRef} className="flex-1 min-w-0 truncate">
{doc.title}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs break-words">
{doc.title}
</TooltipContent>
</Tooltip>
<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 +251,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 +267,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,11 +276,13 @@ export const DocumentNode = React.memo(function DocumentNode({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isProcessing}
onClick={() => onDelete(doc)}
>
{onVersionHistory && isVersionableType(doc.document_type) && (
<DropdownMenuItem disabled={isProcessing} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
@ -259,7 +293,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 +309,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,11 +318,13 @@ export const DocumentNode = React.memo(function DocumentNode({
</ContextMenuSubContent>
</ContextMenuSub>
)}
<ContextMenuItem
className="text-destructive focus:text-destructive"
disabled={isProcessing}
onClick={() => onDelete(doc)}
>
{onVersionHistory && isVersionableType(doc.document_type) && (
<ContextMenuItem disabled={isProcessing} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</ContextMenuItem>
)}
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>

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";
@ -50,8 +56,8 @@ interface FolderNodeProps {
depth: number;
isExpanded: boolean;
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 +76,9 @@ interface FolderNodeProps {
disabledDropIds?: Set<number>;
contextMenuOpen?: boolean;
onContextMenuOpenChange?: (open: boolean) => void;
isWatched?: boolean;
onRescan?: (folder: FolderDisplay) => void;
onStopWatching?: (folder: FolderDisplay) => void;
}
function getDropZone(
@ -91,8 +100,8 @@ export const FolderNode = React.memo(function FolderNode({
depth,
isExpanded,
isRenaming,
childCount,
selectionState,
processingState,
onToggleSelect,
onToggleExpand,
onRename,
@ -107,6 +116,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 +254,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 +276,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" />
@ -289,12 +334,6 @@ export const FolderNode = React.memo(function FolderNode({
<span className="flex-1 min-w-0 truncate">{folder.name}</span>
)}
{!isRenaming && childCount > 0 && (
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
{childCount}
</span>
)}
{!isRenaming && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -308,6 +347,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 +414,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,25 +73,19 @@ 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]);
const docsByFolder = useMemo(() => groupBy(documents, (d) => d.folderId ?? "root"), [documents]);
const folderChildCounts = useMemo(() => {
const counts: Record<number, number> = {};
for (const f of folders) {
const children = foldersByParent[f.id] ?? [];
const docs = docsByFolder[f.id] ?? [];
counts[f.id] = children.length + docs.length;
}
return counts;
}, [folders, foldersByParent, docsByFolder]);
const [openContextMenuId, setOpenContextMenuId] = useState<string | null>(null);
// Single subscription for rename state — derived boolean passed to each FolderNode
@ -98,14 +96,26 @@ export function FolderTreeView({
);
const handleCancelRename = useCallback(() => setRenamingFolderId(null), [setRenamingFolderId]);
const effectiveActiveTypes = useMemo(() => {
if (
activeTypes.includes("FILE" as DocumentTypeEnum) &&
!activeTypes.includes("LOCAL_FOLDER_FILE" as DocumentTypeEnum)
) {
return [...activeTypes, "LOCAL_FOLDER_FILE" as DocumentTypeEnum];
}
return activeTypes;
}, [activeTypes]);
const hasDescendantMatch = useMemo(() => {
if (activeTypes.length === 0 && !searchQuery) return null;
if (effectiveActiveTypes.length === 0 && !searchQuery) return null;
const match: Record<number, boolean> = {};
function check(folderId: number): boolean {
if (match[folderId] !== undefined) return match[folderId];
const childDocs = (docsByFolder[folderId] ?? []).some(
(d) => activeTypes.length === 0 || activeTypes.includes(d.document_type as DocumentTypeEnum)
(d) =>
effectiveActiveTypes.length === 0 ||
effectiveActiveTypes.includes(d.document_type as DocumentTypeEnum)
);
if (childDocs) {
match[folderId] = true;
@ -126,7 +136,7 @@ export function FolderTreeView({
check(f.id);
}
return match;
}, [folders, docsByFolder, foldersByParent, activeTypes, searchQuery]);
}, [folders, docsByFolder, foldersByParent, effectiveActiveTypes, searchQuery]);
const folderSelectionStates = useMemo(() => {
const states: Record<number, FolderSelectionState> = {};
@ -158,6 +168,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] ?? [])
@ -167,7 +206,9 @@ export function FolderTreeView({
? childFolders.filter((f) => hasDescendantMatch[f.id])
: childFolders;
const childDocs = (docsByFolder[key] ?? []).filter(
(d) => activeTypes.length === 0 || activeTypes.includes(d.document_type as DocumentTypeEnum)
(d) =>
effectiveActiveTypes.length === 0 ||
effectiveActiveTypes.includes(d.document_type as DocumentTypeEnum)
);
const nodes: React.ReactNode[] = [];
@ -189,8 +230,8 @@ export function FolderTreeView({
depth={depth}
isExpanded={isExpanded}
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 +245,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 +269,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)}
/>
@ -247,11 +292,12 @@ export function FolderTreeView({
);
}
if (treeNodes.length === 0 && (activeTypes.length > 0 || searchQuery)) {
if (treeNodes.length === 0 && (effectiveActiveTypes.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,28 +1,34 @@
"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";
import { useMediaQuery } from "@/hooks/use-media-query";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
{ ssr: false, loading: () => <Skeleton className="h-64 w-full" /> }
{ ssr: false, loading: () => <EditorPanelSkeleton /> }
);
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 +68,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 +76,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 +87,7 @@ export function EditorPanelContent({
initialLoadDone.current = false;
changeCountRef.current = 0;
const fetchContent = async () => {
const doFetch = async () => {
const token = getBearerToken();
if (!token) {
redirectToLogin();
@ -86,12 +95,14 @@ 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" }
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 +126,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 +184,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 +196,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 +214,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

@ -158,15 +158,18 @@ export function PlateEditor({
// When not forced read-only, the user can toggle between editing/viewing.
const canToggleMode = !readOnly;
const contextProviderValue = useMemo(
() => ({
onSave,
hasUnsavedChanges,
isSaving,
canToggleMode,
}),
[onSave, hasUnsavedChanges, isSaving, canToggleMode]
);
return (
<EditorSaveContext.Provider
value={{
onSave,
hasUnsavedChanges,
isSaving,
canToggleMode,
}}
>
<EditorSaveContext.Provider value={contextProviderValue}>
<Plate
editor={editor}
// Only pass readOnly as a controlled prop when forced (permanently read-only).

View file

@ -408,6 +408,7 @@ const AudioCommentIllustration = () => (
src="/homepage/comments-audio.webp"
alt="Audio Comment Illustration"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover"
/>
</div>

View file

@ -1,39 +1,15 @@
"use client";
import { Monitor } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import dynamic from "next/dynamic";
import Link from "next/link";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import React, { memo, useCallback, useEffect, useRef, useState } from "react";
import Balancer from "react-wrap-balancer";
import { ExpandedMediaOverlay, useExpandedMedia } from "@/components/ui/expanded-gif-overlay";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
import { trackLoginAttempt } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
const HeroCarousel = dynamic(
() => import("@/components/ui/hero-carousel").then((m) => ({ default: m.HeroCarousel })),
{
ssr: false,
loading: () => (
<div className="w-full py-4 sm:py-8">
<div className="mx-auto w-full max-w-[900px]">
<div className="overflow-hidden rounded-2xl border border-neutral-200/60 bg-white shadow-xl sm:rounded-3xl dark:border-neutral-700/60 dark:bg-neutral-900">
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
<div className="min-w-0 flex-1">
<div className="h-5 w-32 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700" />
<div className="mt-2 h-4 w-64 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
</div>
</div>
<div className="bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950">
<div className="aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
</div>
</div>
</div>
</div>
),
}
);
// Official Google "G" logo with brand colors
const GoogleLogo = ({ className }: { className?: string }) => (
<svg
className={className}
@ -62,87 +38,99 @@ const GoogleLogo = ({ className }: { className?: string }) => (
</svg>
);
function useIsDesktop(breakpoint = 1024) {
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
const mql = window.matchMedia(`(min-width: ${breakpoint}px)`);
setIsDesktop(mql.matches);
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [breakpoint]);
return isDesktop;
}
const TAB_ITEMS = [
{
title: "Connect & Sync",
description:
"Connect data sources like Notion, Drive and Gmail. Automatically sync to keep them updated.",
src: "/homepage/hero_tutorial/ConnectorFlowGif.mp4",
featured: true,
},
{
title: "Upload Documents",
description: "Upload documents directly, from images to massive PDFs.",
src: "/homepage/hero_tutorial/DocUploadGif.mp4",
featured: true,
},
{
title: "Search & Citation",
description: "Ask questions and get cited responses from your knowledge base.",
src: "/homepage/hero_tutorial/BSNCGif.mp4",
featured: false,
},
{
title: "Document Q&A",
description: "Mention specific documents in chat for targeted answers.",
src: "/homepage/hero_tutorial/BQnaGif_compressed.mp4",
featured: false,
},
{
title: "Reports",
description: "Generate reports from your sources in many formats.",
src: "/homepage/hero_tutorial/ReportGenGif_compressed.mp4",
featured: false,
},
{
title: "Podcasts",
description: "Turn anything into a podcast in under 20 seconds.",
src: "/homepage/hero_tutorial/PodcastGenGif.mp4",
featured: false,
},
{
title: "Image Generation",
description: "Generate high-quality images easily from your conversations.",
src: "/homepage/hero_tutorial/ImageGenGif.mp4",
featured: false,
},
{
title: "Collaborative Chat",
description: "Collaborate on AI-powered conversations in realtime with your team.",
src: "/homepage/hero_realtime/RealTimeChatGif.mp4",
featured: false,
},
{
title: "Comments",
description: "Add comments and tag teammates on any message.",
src: "/homepage/hero_realtime/RealTimeCommentsFlow.mp4",
featured: false,
},
{
title: "Video Generation",
description: "Create short videos with AI-generated visuals and narration from your sources.",
src: "/homepage/hero_tutorial/video_gen_surf.mp4",
featured: false,
},
] as const;
export function HeroSection() {
const containerRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null);
const isDesktop = useIsDesktop();
return (
<div
ref={parentRef}
className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-24 md:px-8 md:py-48"
>
<BackgroundGrids />
{isDesktop && (
<>
<CollisionMechanism
parentRef={parentRef}
beamOptions={{
initialX: -400,
translateX: 600,
duration: 7,
repeatDelay: 3,
}}
/>
<CollisionMechanism
parentRef={parentRef}
beamOptions={{
initialX: -200,
translateX: 800,
duration: 4,
repeatDelay: 3,
}}
/>
<CollisionMechanism
parentRef={parentRef}
beamOptions={{
initialX: 200,
translateX: 1200,
duration: 5,
repeatDelay: 3,
}}
/>
<CollisionMechanism
parentRef={parentRef}
beamOptions={{
initialX: 400,
translateX: 1400,
duration: 6,
repeatDelay: 3,
}}
/>
</>
)}
<div className="mx-auto w-full max-w-7xl min-w-0 pt-36">
<div className="mt-4 flex w-full min-w-0 flex-col items-start px-2 md:px-8 xl:px-0">
<h1
className={cn(
"relative mt-4 max-w-7xl text-left text-4xl font-bold tracking-tight text-balance text-neutral-900 sm:text-5xl md:text-6xl xl:text-8xl dark:text-neutral-50"
)}
>
<Balancer>NotebookLM for Teams</Balancer>
</h1>
<div className="mt-4 flex w-full flex-col items-start justify-between gap-4 md:mt-12 md:flex-row md:items-end md:gap-10">
<div>
<h2
className={cn(
"relative mb-8 max-w-2xl text-left text-sm tracking-wide text-neutral-600 antialiased sm:text-base md:text-xl dark:text-neutral-400"
)}
>
An open source, privacy focused alternative to NotebookLM for teams with no data
limits.
</h2>
<h2 className="relative z-50 mx-auto mb-4 mt-8 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300">
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
<Balancer>NotebookLM for Teams</Balancer>
<div className="relative mb-4 flex w-full flex-col justify-center gap-y-2 sm:flex-row sm:justify-start sm:space-y-0 sm:space-x-4">
<GetStartedButton />
</div>
</div>
<DownloadApp />
</div>
</h2>
<p className="relative z-50 mx-auto mt-4 max-w-lg px-6 text-center text-sm leading-relaxed text-gray-600 sm:text-base sm:leading-relaxed md:max-w-xl md:text-lg md:leading-relaxed dark:text-gray-200">
Connect any LLM to your internal knowledge sources and chat with it in real time alongside
your team.
</p>
<div className="mb-6 mt-6 flex w-full flex-col items-center justify-center gap-4 px-8 sm:flex-row md:mb-10">
<GetStartedButton />
{/* <ContactSalesButton /> */}
</div>
<div ref={containerRef} className="relative w-full z-51">
<HeroCarousel />
<BrowserWindow />
</div>
</div>
);
@ -158,256 +146,247 @@ function GetStartedButton() {
if (isGoogleAuth) {
return (
<motion.button
<button
type="button"
onClick={handleGoogleLogin}
whileHover="hover"
whileTap={{ scale: 0.98 }}
initial="idle"
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-3 overflow-hidden rounded-xl bg-white px-6 py-2.5 text-sm font-semibold text-neutral-700 shadow-lg ring-1 ring-neutral-200/50 transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50"
variants={{
idle: { scale: 1, y: 0 },
hover: { scale: 1.02, y: -2 },
}}
className="flex h-14 w-full cursor-pointer items-center justify-center gap-3 rounded-lg bg-white text-center text-base font-medium text-neutral-700 shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 hover:bg-neutral-50 sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50 dark:hover:bg-neutral-800"
>
{/* Animated gradient background on hover */}
<motion.div
className="absolute inset-0 bg-linear-to-r from-blue-50 via-green-50 to-yellow-50 dark:from-blue-950/30 dark:via-green-950/30 dark:to-yellow-950/30"
variants={{
idle: { opacity: 0 },
hover: { opacity: 1 },
}}
transition={{ duration: 0.3 }}
/>
{/* Google logo with subtle animation */}
<motion.div
className="relative"
variants={{
idle: { rotate: 0 },
hover: { rotate: [0, -8, 8, 0] },
}}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
<GoogleLogo className="h-5 w-5" />
</motion.div>
<span className="relative">Continue with Google</span>
</motion.button>
<GoogleLogo className="h-5 w-5" />
<span>Continue with Google</span>
</button>
);
}
return (
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
<Link
href="/login"
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-black px-6 py-2.5 text-sm font-semibold text-white shadow-lg transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-white dark:text-black"
>
Get Started
</Link>
</motion.div>
<Link
href="/login"
className="flex h-14 w-full items-center justify-center rounded-lg bg-black text-center text-base font-medium text-white shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 sm:w-52 dark:bg-white dark:text-black"
>
Get Started
</Link>
);
}
const BackgroundGrids = () => {
return (
<div className="pointer-events-none absolute inset-0 z-0 grid h-screen w-full -rotate-45 transform select-none grid-cols-2 gap-10 md:grid-cols-4">
<div className="relative h-full w-full">
<GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" />
</div>
<div className="relative h-full w-full">
<GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" />
</div>
<div className="relative h-full w-full bg-linear-to-b from-transparent via-neutral-100 to-transparent dark:via-neutral-800">
<GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" />
</div>
<div className="relative h-full w-full">
<GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" />
</div>
</div>
);
};
const CollisionMechanism = ({
parentRef,
beamOptions = {},
}: {
parentRef: React.RefObject<HTMLDivElement | null>;
beamOptions?: {
initialX?: number;
translateX?: number;
initialY?: number;
translateY?: number;
rotate?: number;
className?: string;
duration?: number;
delay?: number;
repeatDelay?: number;
};
}) => {
const beamRef = useRef<HTMLDivElement>(null);
const [collision, setCollision] = useState<{
detected: boolean;
coordinates: { x: number; y: number } | null;
}>({ detected: false, coordinates: null });
const [beamKey, setBeamKey] = useState(0);
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
useEffect(() => {
const checkCollision = () => {
if (beamRef.current && parentRef.current && !cycleCollisionDetected) {
const beamRect = beamRef.current.getBoundingClientRect();
const parentRect = parentRef.current.getBoundingClientRect();
const rightEdge = parentRect.right;
if (beamRect.right >= rightEdge - 20) {
const relativeX = parentRect.width - 20;
const relativeY = beamRect.top - parentRect.top + beamRect.height / 2;
setCollision({
detected: true,
coordinates: { x: relativeX, y: relativeY },
});
setCycleCollisionDetected(true);
if (beamRef.current) {
beamRef.current.style.opacity = "0";
}
}
}
};
const animationInterval = setInterval(checkCollision, 100);
return () => clearInterval(animationInterval);
}, [cycleCollisionDetected, parentRef]);
useEffect(() => {
if (!collision.detected || !collision.coordinates) return;
const timer1 = setTimeout(() => {
setCollision({ detected: false, coordinates: null });
setCycleCollisionDetected(false);
if (beamRef.current) {
beamRef.current.style.opacity = "1";
}
}, 2000);
const timer2 = setTimeout(() => {
setBeamKey((prevKey) => prevKey + 1);
}, 2000);
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
};
}, [collision]);
const BrowserWindow = () => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectedItem = TAB_ITEMS[selectedIndex];
const { expanded, open, close } = useExpandedMedia();
return (
<>
<motion.div
key={beamKey}
ref={beamRef}
animate="animate"
initial={{
translateY: beamOptions.initialY || "-200px",
translateX: beamOptions.initialX || "0px",
rotate: beamOptions.rotate || -45,
}}
variants={{
animate: {
translateY: beamOptions.translateY || "800px",
translateX: beamOptions.translateX || "700px",
rotate: beamOptions.rotate || -45,
},
}}
transition={{
duration: beamOptions.duration || 8,
repeat: Infinity,
repeatType: "loop",
ease: "linear",
delay: beamOptions.delay || 0,
repeatDelay: beamOptions.repeatDelay || 0,
}}
className={cn(
"absolute left-96 top-20 m-auto h-14 w-px rounded-full bg-linear-to-t from-orange-500 via-yellow-500 to-transparent will-change-transform",
beamOptions.className
)}
/>
<motion.div className="relative my-4 flex w-full flex-col items-start justify-start overflow-hidden rounded-2xl shadow-2xl md:my-12">
<div className="flex w-full items-center justify-start overflow-hidden bg-gray-200 py-4 pl-4 dark:bg-neutral-800">
<div className="mr-6 flex items-center gap-2">
<div className="size-3 rounded-full bg-red-500" />
<div className="size-3 rounded-full bg-yellow-500" />
<div className="size-3 rounded-full bg-green-500" />
</div>
<div className="no-visible-scrollbar flex min-w-0 shrink flex-row items-center justify-start gap-2 overflow-x-auto mask-l-from-98% py-0.5 pr-2 pl-2 md:pl-4">
{TAB_ITEMS.map((item, index) => (
<React.Fragment key={item.title}>
<button
type="button"
onClick={() => setSelectedIndex(index)}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs transition duration-150 hover:bg-white sm:text-sm dark:hover:bg-neutral-950",
selectedIndex === index &&
!item.featured &&
"bg-white shadow ring-1 shadow-black/10 ring-black/10 dark:bg-neutral-900",
selectedIndex === index &&
item.featured &&
"bg-amber-50 shadow ring-1 shadow-amber-200/50 ring-amber-400/60 dark:bg-amber-950/40 dark:shadow-amber-900/30 dark:ring-amber-500/50",
item.featured &&
selectedIndex !== index &&
"hover:bg-amber-50 dark:hover:bg-amber-950/30"
)}
>
{item.title}
{item.featured && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 items-center justify-center rounded border border-amber-300 bg-amber-100 p-0.5 text-amber-700 dark:border-amber-700 dark:bg-amber-900/50 dark:text-amber-400">
<Monitor className="size-3" />
</span>
</TooltipTrigger>
<TooltipContent side="bottom">Desktop app only</TooltipContent>
</Tooltip>
)}
</button>
{index !== TAB_ITEMS.length - 1 && (
<div className="h-4 w-px shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
)}
</React.Fragment>
))}
</div>
</div>
<div className="w-full overflow-hidden bg-gray-100/50 px-4 pt-4 perspective-distant dark:bg-neutral-950">
<AnimatePresence mode="wait">
<motion.div
initial={{
opacity: 0,
scale: 0.99,
filter: "blur(10px)",
}}
animate={{
opacity: 1,
scale: 1,
filter: "blur(0px)",
}}
exit={{
opacity: 0,
scale: 0.98,
filter: "blur(10px)",
}}
transition={{
duration: 0.3,
ease: "easeOut",
}}
key={selectedItem.title}
className="relative overflow-hidden rounded-tl-xl rounded-tr-xl bg-white shadow-sm ring-1 shadow-black/10 ring-black/10 will-change-transform dark:bg-neutral-950"
>
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-neutral-900 sm:text-lg dark:text-white">
{selectedItem.title}
</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{selectedItem.description}
</p>
</div>
</div>
<button
type="button"
className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950 w-full"
onClick={open}
>
<TabVideo src={selectedItem.src} />
</button>
</motion.div>
</AnimatePresence>
</div>
</motion.div>
<AnimatePresence>
{collision.detected && collision.coordinates && (
<Explosion
key={`${collision.coordinates.x}-${collision.coordinates.y}`}
className=""
style={{
left: `${collision.coordinates.x + 20}px`,
top: `${collision.coordinates.y}px`,
transform: "translate(-50%, -50%)",
}}
/>
{expanded && (
<ExpandedMediaOverlay src={selectedItem.src} alt={selectedItem.title} onClose={close} />
)}
</AnimatePresence>
</>
);
};
const Explosion = ({ ...props }: React.HTMLProps<HTMLDivElement>) => {
const spans = Array.from({ length: 20 }, (_, index) => ({
id: index,
initialX: 0,
initialY: 0,
directionX: Math.floor(Math.random() * 80 - 40),
directionY: Math.floor(Math.random() * -50 - 10),
}));
const TabVideo = memo(function TabVideo({ src }: { src: string }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [hasLoaded, setHasLoaded] = useState(false);
useEffect(() => {
setHasLoaded(false);
const video = videoRef.current;
if (!video) return;
video.currentTime = 0;
video.play().catch(() => {});
}, [src]);
const handleCanPlay = useCallback(() => {
setHasLoaded(true);
}, []);
return (
<div {...props} className={cn("absolute z-50 h-2 w-2", props.className)}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: [0, 1, 0] }}
exit={{ opacity: 0 }}
transition={{ duration: 1, ease: "easeOut" }}
className="absolute -inset-x-10 top-0 m-auto h-[4px] w-10 rounded-full bg-linear-to-r from-transparent via-orange-500 to-transparent blur-sm"
></motion.div>
{spans.map((span) => (
<motion.span
key={span.id}
initial={{ x: span.initialX, y: span.initialY, opacity: 1 }}
animate={{ x: span.directionX, y: span.directionY, opacity: 0 }}
transition={{ duration: Math.random() * 1.5 + 0.5, ease: "easeOut" }}
className="absolute h-1 w-1 rounded-full bg-linear-to-b from-orange-500 to-yellow-500"
/>
))}
<div className="relative">
<video
ref={videoRef}
key={src}
src={src}
preload="auto"
loop
muted
playsInline
onCanPlay={handleCanPlay}
className="aspect-video w-full rounded-lg sm:rounded-xl"
/>
{!hasLoaded && (
<div className="absolute inset-0 aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
)}
</div>
);
};
});
const GridLineVertical = ({ className, offset }: { className?: string; offset?: string }) => {
const GITHUB_RELEASES_URL = "https://github.com/MODSetter/SurfSense/releases/latest";
const DownloadApp = memo(function DownloadApp() {
return (
<div
style={
{
"--background": "#ffffff",
"--color": "rgba(0, 0, 0, 0.2)",
"--height": "5px",
"--width": "1px",
"--fade-stop": "90%",
"--offset": offset || "150px", //-100px if you want to keep the line inside
"--color-dark": "rgba(255, 255, 255, 0.3)",
maskComposite: "exclude",
} as React.CSSProperties
}
className={cn(
"absolute top-[calc(var(--offset)/2*-1)] h-[calc(100%+var(--offset))] w-(--width)",
"bg-[linear-gradient(to_bottom,var(--color),var(--color)_50%,transparent_0,transparent)]",
"bg-size-[var(--width)_var(--height)]",
"[mask:linear-gradient(to_top,var(--background)_var(--fade-stop),transparent),linear-gradient(to_bottom,var(--background)_var(--fade-stop),transparent),linear-gradient(black,black)]",
"mask-exclude",
"z-30",
"dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
className
)}
></div>
<div className="flex flex-col items-start justify-start">
<p className="mb-4 text-left text-sm text-neutral-500 lg:text-lg dark:text-neutral-400">
Download the desktop app
</p>
<div className="mb-2 flex flex-row flex-wrap items-center gap-3">
<a
href={GITHUB_RELEASES_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 shadow-sm transition hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
<svg
className="size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-label="Download for macOS"
>
<path d="M12 17V3" />
<path d="m6 11 6 6 6-6" />
<path d="M19 21H5" />
</svg>
macOS
</a>
<a
href={GITHUB_RELEASES_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 shadow-sm transition hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
<svg
className="size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-label="Download for Windows"
>
<path d="M12 17V3" />
<path d="m6 11 6 6 6-6" />
<path d="M19 21H5" />
</svg>
Windows
</a>
<a
href={GITHUB_RELEASES_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 shadow-sm transition hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
<svg
className="size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-label="Download for Linux"
>
<path d="M12 17V3" />
<path d="m6 11 6 6 6-6" />
<path d="M19 21H5" />
</svg>
Linux
</a>
</div>
</div>
);
};
});

View file

@ -32,7 +32,7 @@ export const Navbar = ({ scrolledBgClassName }: NavbarProps = {}) => {
};
handleScroll();
window.addEventListener("scroll", handleScroll);
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
@ -132,7 +132,7 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: any) => {
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("touchstart", handleClickOutside);
document.addEventListener("touchstart", handleClickOutside, { passive: true });
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("touchstart", handleClickOutside);
@ -143,7 +143,6 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: any) => {
<motion.div
ref={navRef}
animate={{ borderRadius: open ? "4px" : "2rem" }}
key={String(open)}
className={cn(
"relative mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-col items-center justify-between px-4 py-2 lg:hidden transition-all duration-300",
isScrolled

View file

@ -1,6 +1,7 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay";
const useCases = [
@ -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

@ -55,7 +55,7 @@ import { useInbox } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { logout } from "@/lib/auth-utils";
import { getLoginPath, logout } from "@/lib/auth-utils";
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
@ -347,35 +347,38 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
// Navigation items
const navItems: NavItem[] = useMemo(
() => [
{
title: "Inbox",
url: "#inbox",
icon: Inbox,
isActive: isInboxSidebarOpen,
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
},
{
title: "Documents",
url: "#documents",
icon: SquareLibrary,
isActive: isMobile
? isDocumentsSidebarOpen
: isDocumentsSidebarOpen && !isRightPanelCollapsed,
},
{
title: "Announcements",
url: "#announcements",
icon: Megaphone,
isActive: isAnnouncementsSidebarOpen,
badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
},
],
() =>
(
[
{
title: "Inbox",
url: "#inbox",
icon: Inbox,
isActive: isInboxSidebarOpen,
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
},
isMobile
? {
title: "Documents",
url: "#documents",
icon: SquareLibrary,
isActive: isDocumentsSidebarOpen,
}
: null,
{
title: "Announcements",
url: "#announcements",
icon: Megaphone,
isActive: isAnnouncementsSidebarOpen,
badge:
announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
},
] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null),
[
isMobile,
isInboxSidebarOpen,
isDocumentsSidebarOpen,
isRightPanelCollapsed,
totalUnreadCount,
isAnnouncementsSidebarOpen,
announcementUnreadCount,
@ -600,12 +603,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
await logout();
if (typeof window !== "undefined") {
router.push("/");
router.push(getLoginPath());
}
} catch (error) {
console.error("Error during logout:", error);
await logout();
router.push("/");
router.push(getLoginPath());
}
}, [router]);
@ -775,7 +778,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 +839,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 +867,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 +895,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

@ -83,7 +83,7 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
<DialogContent className="max-w-[90vw] sm:max-w-sm p-4 sm:p-5 select-none data-[state=open]:animate-none data-[state=closed]:animate-none">
<DialogHeader className="space-y-2 pb-2">
<div className="flex items-center gap-2 sm:gap-3">
<div className="flex-1 min-w-0">
@ -108,7 +108,7 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
placeholder={t("name_placeholder")}
{...field}
autoFocus
className="text-sm h-9 sm:h-10"
className="text-sm h-9 sm:h-10 select-text"
/>
</FormControl>
<FormMessage />
@ -131,7 +131,7 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
<Input
placeholder={t("description_placeholder")}
{...field}
className="text-sm h-9 sm:h-10"
className="text-sm h-9 sm:h-10 select-text"
/>
</FormControl>
<FormMessage />

View file

@ -10,7 +10,6 @@ import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { DocumentsSidebar } from "../sidebar";
@ -19,7 +18,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(
@ -27,7 +26,7 @@ const HitlEditPanelContent = dynamic(
import("@/components/hitl-edit-panel/hitl-edit-panel").then((m) => ({
default: m.HitlEditPanelContent,
})),
{ ssr: false, loading: () => <Skeleton className="h-96 w-full" /> }
{ ssr: false, loading: () => null }
);
const ReportPanelContent = dynamic(
@ -35,7 +34,7 @@ const ReportPanelContent = dynamic(
import("@/components/report-panel/report-panel").then((m) => ({
default: m.ReportPanelContent,
})),
{ ssr: false, loading: () => <Skeleton className="h-96 w-full" /> }
{ ssr: false, loading: () => null }
);
interface RightPanelProps {
@ -78,14 +77,14 @@ export function RightPanelExpandButton() {
if (!collapsed || !hasContent) return null;
return (
<div className="flex shrink-0 items-center px-1">
<div className="flex shrink-0 items-center px-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => startTransition(() => setCollapsed(false))}
className="h-7 w-7 shrink-0"
className="h-8 w-8 shrink-0 -m-0.5"
>
<PanelRight className="h-4 w-4" />
<span className="sr-only">Expand panel</span>

View file

@ -350,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",
@ -376,7 +376,7 @@ export function AllPrivateChatsSidebarContent({
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
) : (
<Tooltip>
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>
<button
type="button"

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",
@ -375,7 +375,7 @@ export function AllSharedChatsSidebarContent({
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
) : (
<Tooltip>
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>
<button
type="button"

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,8 @@ 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 { useElectronAPI } from "@/hooks/use-platform";
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";
@ -82,6 +85,7 @@ export function DocumentsSidebar({
const tSidebar = useTranslations("sidebar");
const params = useParams();
const isMobile = !useMediaQuery("(min-width: 640px)");
const electronAPI = useElectronAPI();
const searchSpaceId = Number(params.search_space_id);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
@ -92,6 +96,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(() => {
if (!electronAPI?.getWatchedFolders) return;
const api = electronAPI;
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, electronAPI]);
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
@ -134,7 +182,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 +276,54 @@ export function DocumentsSidebar({
[createFolderParentId, searchSpaceId, setExpandedFolderMap]
);
const handleRescanFolder = useCallback(
async (folder: FolderDisplay) => {
if (!electronAPI) return;
const watchedFolders = await electronAPI.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, electronAPI]
);
const handleStopWatching = useCallback(
async (folder: FolderDisplay) => {
if (!electronAPI) return;
const watchedFolders = await electronAPI.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
if (!matched) {
toast.error("This folder is not being watched");
return;
}
await electronAPI.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}`);
},
[electronAPI]
);
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
try {
await foldersApiService.updateFolder(folder.id, { name: newName });
@ -232,15 +333,25 @@ export function DocumentsSidebar({
}
}, []);
const handleDeleteFolder = useCallback(async (folder: FolderDisplay) => {
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
try {
await foldersApiService.deleteFolder(folder.id);
toast.success("Folder deleted");
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to delete folder");
}
}, []);
const handleDeleteFolder = useCallback(
async (folder: FolderDisplay) => {
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
try {
if (electronAPI) {
const watchedFolders = await electronAPI.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
if (matched) {
await electronAPI.removeWatchedFolder(matched.path);
}
}
await foldersApiService.deleteFolder(folder.id);
toast.success("Folder deleted");
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to delete folder");
}
},
[electronAPI]
);
const handleMoveFolder = useCallback(
(folder: FolderDisplay) => {
@ -425,7 +536,8 @@ export function DocumentsSidebar({
const typeCounts = useMemo(() => {
const counts: Partial<Record<string, number>> = {};
for (const d of treeDocuments) {
counts[d.document_type] = (counts[d.document_type] || 0) + 1;
const displayType = d.document_type === "LOCAL_FOLDER_FILE" ? "FILE" : d.document_type;
counts[displayType] = (counts[displayType] || 0) + 1;
}
return counts;
}, [treeDocuments]);
@ -448,6 +560,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;
@ -639,7 +752,7 @@ export function DocumentsSidebar({
</button>
</div>
<div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
<div className="flex-1 min-h-0 pt-0 flex flex-col">
<div className="px-4 pb-2">
<DocumentsFilters
typeCounts={typeCounts}
@ -651,56 +764,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,
@ -780,36 +790,23 @@ export function InboxSidebarContent({
</DropdownMenuContent>
</DropdownMenu>
)}
{isMobile ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full"
onClick={handleMarkAllAsRead}
disabled={totalUnreadCount === 0}
>
<CheckCheck className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full"
onClick={handleMarkAllAsRead}
disabled={totalUnreadCount === 0}
>
<CheckCheck className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{t("mark_all_read") || "Mark all as read"}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full"
onClick={handleMarkAllAsRead}
disabled={totalUnreadCount === 0}
>
<CheckCheck className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{t("mark_all_read") || "Mark all as read"}
</TooltipContent>
</Tooltip>
</div>
</div>
@ -920,31 +917,10 @@ export function InboxSidebarContent({
"transition-colors cursor-pointer",
isMarkingAsRead && "opacity-50 pointer-events-none"
)}
style={{ contentVisibility: "auto", containIntrinsicSize: "0 80px" }}
>
{isMobile ? (
<button
type="button"
onClick={() => handleItemClick(item)}
disabled={isMarkingAsRead}
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
>
<div className="shrink-0">{getStatusIcon(item)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<p
className={cn(
"text-xs font-medium line-clamp-2",
!item.read && "font-semibold"
)}
>
{item.title}
</p>
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
{convertRenderedToDisplay(item.message)}
</p>
</div>
</button>
) : (
<Tooltip>
{activeTab === "status" ? (
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>
<button
type="button"
@ -975,6 +951,28 @@ export function InboxSidebarContent({
</p>
</TooltipContent>
</Tooltip>
) : (
<button
type="button"
onClick={() => handleItemClick(item)}
disabled={isMarkingAsRead}
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
>
<div className="shrink-0">{getStatusIcon(item)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<p
className={cn(
"text-xs font-medium line-clamp-2",
!item.read && "font-semibold"
)}
>
{item.title}
</p>
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
{convertRenderedToDisplay(item.message)}
</p>
</div>
</button>
)}
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">

View file

@ -35,7 +35,7 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
<Progress value={usagePercentage} className="h-1.5" />
<Link
href={`/dashboard/${searchSpaceId}/more-pages`}
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent"
className="group flex w-[calc(100%+0.75rem)] items-center justify-between rounded-md px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent"
>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<Zap className="h-3 w-3 shrink-0" />
@ -48,7 +48,7 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
{pageBuyingEnabled && (
<Link
href={`/dashboard/${searchSpaceId}/buy-pages`}
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent"
className="group flex w-[calc(100%+0.75rem)] items-center justify-between rounded-md px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent"
>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<CreditCard className="h-3 w-3 shrink-0" />

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();
@ -72,12 +81,14 @@ 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" }
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 +109,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 +169,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 +256,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

@ -2,6 +2,8 @@ import { createCodePlugin } from "@streamdown/code";
import { createMathPlugin } from "@streamdown/math";
import { Streamdown, type StreamdownProps } from "streamdown";
import "katex/dist/katex.min.css";
import { is } from "drizzle-orm";
import Image from "next/image";
import { cn } from "@/lib/utils";
const code = createCodePlugin({
@ -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,32 @@ 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 +192,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

@ -3,11 +3,14 @@
import { useCallback, useState } from "react";
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
import { VisionConfigDialog } from "@/components/shared/vision-config-dialog";
import type {
GlobalImageGenConfig,
GlobalNewLLMConfig,
GlobalVisionLLMConfig,
ImageGenerationConfig,
NewLLMConfigPublic,
VisionLLMConfig,
} from "@/contracts/types/new-llm-config.types";
import { ModelSelector } from "./model-selector";
@ -33,6 +36,14 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
const [isImageGlobal, setIsImageGlobal] = useState(false);
const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view");
// Vision config dialog state
const [visionDialogOpen, setVisionDialogOpen] = useState(false);
const [selectedVisionConfig, setSelectedVisionConfig] = useState<
VisionLLMConfig | GlobalVisionLLMConfig | null
>(null);
const [isVisionGlobal, setIsVisionGlobal] = useState(false);
const [visionDialogMode, setVisionDialogMode] = useState<"create" | "edit" | "view">("view");
// LLM handlers
const handleEditLLMConfig = useCallback(
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
@ -79,6 +90,29 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
if (!open) setSelectedImageConfig(null);
}, []);
// Vision model handlers
const handleAddVisionModel = useCallback(() => {
setSelectedVisionConfig(null);
setIsVisionGlobal(false);
setVisionDialogMode("create");
setVisionDialogOpen(true);
}, []);
const handleEditVisionConfig = useCallback(
(config: VisionLLMConfig | GlobalVisionLLMConfig, global: boolean) => {
setSelectedVisionConfig(config);
setIsVisionGlobal(global);
setVisionDialogMode(global ? "view" : "edit");
setVisionDialogOpen(true);
},
[]
);
const handleVisionDialogClose = useCallback((open: boolean) => {
setVisionDialogOpen(open);
if (!open) setSelectedVisionConfig(null);
}, []);
return (
<div className="flex items-center gap-2">
<ModelSelector
@ -86,6 +120,8 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
onAddNewLLM={handleAddNewLLM}
onEditImage={handleEditImageConfig}
onAddNewImage={handleAddImageModel}
onEditVision={handleEditVisionConfig}
onAddNewVision={handleAddVisionModel}
className={className}
/>
<ModelConfigDialog
@ -104,6 +140,14 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
searchSpaceId={searchSpaceId}
mode={imageDialogMode}
/>
<VisionConfigDialog
open={visionDialogOpen}
onOpenChange={handleVisionDialogClose}
config={selectedVisionConfig}
isGlobal={isVisionGlobal}
searchSpaceId={searchSpaceId}
mode={visionDialogMode}
/>
</div>
);
}

View file

@ -163,21 +163,16 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
)}
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0 select-none"
>
<CurrentIcon className="h-4 w-4" />
<span className="hidden md:inline text-sm">{buttonLabel}</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Share settings</TooltipContent>
</Tooltip>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0 select-none"
>
<CurrentIcon className="h-4 w-4" />
<span className="hidden md:inline text-sm">{buttonLabel}</span>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"

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

@ -1,7 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Zap } from "lucide-react";
import { Bot, Check, ChevronDown, Edit3, Eye, ImageIcon, Plus, Search, Zap } from "lucide-react";
import { type UIEvent, useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import {
@ -15,6 +15,10 @@ import {
newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import {
globalVisionLLMConfigsAtom,
visionLLMConfigsAtom,
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -32,8 +36,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
GlobalImageGenConfig,
GlobalNewLLMConfig,
GlobalVisionLLMConfig,
ImageGenerationConfig,
NewLLMConfigPublic,
VisionLLMConfig,
} from "@/contracts/types/new-llm-config.types";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
@ -43,6 +49,8 @@ interface ModelSelectorProps {
onAddNewLLM: () => void;
onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void;
onAddNewImage?: () => void;
onEditVision?: (config: VisionLLMConfig | GlobalVisionLLMConfig, isGlobal: boolean) => void;
onAddNewVision?: () => void;
className?: string;
}
@ -51,14 +59,18 @@ export function ModelSelector({
onAddNewLLM,
onEditImage,
onAddNewImage,
onEditVision,
onAddNewVision,
className,
}: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"llm" | "image">("llm");
const [activeTab, setActiveTab] = useState<"llm" | "image" | "vision">("llm");
const [llmSearchQuery, setLlmSearchQuery] = useState("");
const [imageSearchQuery, setImageSearchQuery] = useState("");
const [visionSearchQuery, setVisionSearchQuery] = useState("");
const [llmScrollPos, setLlmScrollPos] = useState<"top" | "middle" | "bottom">("top");
const [imageScrollPos, setImageScrollPos] = useState<"top" | "middle" | "bottom">("top");
const [visionScrollPos, setVisionScrollPos] = useState<"top" | "middle" | "bottom">("top");
const handleListScroll = useCallback(
(setter: typeof setLlmScrollPos) => (e: UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
@ -82,8 +94,21 @@ export function ModelSelector({
useAtomValue(globalImageGenConfigsAtom);
const { data: imageUserConfigs, isLoading: imageUserLoading } = useAtomValue(imageGenConfigsAtom);
// Vision data
const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } = useAtomValue(
globalVisionLLMConfigsAtom
);
const { data: visionUserConfigs, isLoading: visionUserLoading } =
useAtomValue(visionLLMConfigsAtom);
const isLoading =
llmUserLoading || llmGlobalLoading || prefsLoading || imageGlobalLoading || imageUserLoading;
llmUserLoading ||
llmGlobalLoading ||
prefsLoading ||
imageGlobalLoading ||
imageUserLoading ||
visionGlobalLoading ||
visionUserLoading;
// ─── LLM current config ───
const currentLLMConfig = useMemo(() => {
@ -116,6 +141,24 @@ export function ModelSelector({
);
}, [currentImageConfig]);
// ─── Vision current config ───
const currentVisionConfig = useMemo(() => {
if (!preferences) return null;
const id = preferences.vision_llm_config_id;
if (id === null || id === undefined) return null;
const globalMatch = visionGlobalConfigs?.find((c) => c.id === id);
if (globalMatch) return globalMatch;
return visionUserConfigs?.find((c) => c.id === id) ?? null;
}, [preferences, visionGlobalConfigs, visionUserConfigs]);
const isVisionAutoMode = useMemo(() => {
return (
currentVisionConfig &&
"is_auto_mode" in currentVisionConfig &&
currentVisionConfig.is_auto_mode
);
}, [currentVisionConfig]);
// ─── LLM filtering ───
const filteredLLMGlobal = useMemo(() => {
if (!llmGlobalConfigs) return [];
@ -170,6 +213,33 @@ export function ModelSelector({
const totalImageModels = (imageGlobalConfigs?.length ?? 0) + (imageUserConfigs?.length ?? 0);
// ─── Vision filtering ───
const filteredVisionGlobal = useMemo(() => {
if (!visionGlobalConfigs) return [];
if (!visionSearchQuery) return visionGlobalConfigs;
const q = visionSearchQuery.toLowerCase();
return visionGlobalConfigs.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.model_name.toLowerCase().includes(q) ||
c.provider.toLowerCase().includes(q)
);
}, [visionGlobalConfigs, visionSearchQuery]);
const filteredVisionUser = useMemo(() => {
if (!visionUserConfigs) return [];
if (!visionSearchQuery) return visionUserConfigs;
const q = visionSearchQuery.toLowerCase();
return visionUserConfigs.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.model_name.toLowerCase().includes(q) ||
c.provider.toLowerCase().includes(q)
);
}, [visionUserConfigs, visionSearchQuery]);
const totalVisionModels = (visionGlobalConfigs?.length ?? 0) + (visionUserConfigs?.length ?? 0);
// ─── Handlers ───
const handleSelectLLM = useCallback(
async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
@ -229,6 +299,30 @@ export function ModelSelector({
[currentImageConfig, searchSpaceId, updatePreferences]
);
const handleSelectVision = useCallback(
async (configId: number) => {
if (currentVisionConfig?.id === configId) {
setOpen(false);
return;
}
if (!searchSpaceId) {
toast.error("No search space selected");
return;
}
try {
await updatePreferences({
search_space_id: Number(searchSpaceId),
data: { vision_llm_config_id: configId },
});
toast.success("Vision model updated");
setOpen(false);
} catch {
toast.error("Failed to switch vision model");
}
},
[currentVisionConfig, searchSpaceId, updatePreferences]
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@ -282,6 +376,23 @@ export function ModelSelector({
) : (
<ImageIcon className="size-4 text-muted-foreground" />
)}
{/* Divider */}
<div className="h-4 w-px bg-border/60 dark:bg-white/10 mx-0.5" />
{/* Vision section */}
{currentVisionConfig ? (
<>
{getProviderIcon(currentVisionConfig.provider, {
isAutoMode: isVisionAutoMode ?? false,
})}
<span className="max-w-[80px] md:max-w-[100px] truncate hidden md:inline">
{currentVisionConfig.name}
</span>
</>
) : (
<Eye className="size-4 text-muted-foreground" />
)}
</>
)}
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0" />
@ -295,25 +406,32 @@ export function ModelSelector({
>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "llm" | "image")}
onValueChange={(v) => setActiveTab(v as "llm" | "image" | "vision")}
className="w-full"
>
<div className="border-b border-border/80 dark:border-neutral-800">
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
<TabsList className="w-full grid grid-cols-3 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
<TabsTrigger
value="llm"
className="gap-2 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
className="gap-1.5 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
>
<Zap className="size-4" />
<Zap className="size-3.5" />
LLM
</TabsTrigger>
<TabsTrigger
value="image"
className="gap-2 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
className="gap-1.5 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
>
<ImageIcon className="size-4" />
<ImageIcon className="size-3.5" />
Image
</TabsTrigger>
<TabsTrigger
value="vision"
className="gap-1.5 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
>
<Eye className="size-3.5" />
Vision
</TabsTrigger>
</TabsList>
</div>
@ -344,7 +462,7 @@ export function ModelSelector({
>
<CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2">
<Bot className="size-8 text-muted-foreground" />
<Search className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No models found</p>
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
</div>
@ -498,7 +616,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>
@ -531,8 +649,9 @@ export function ModelSelector({
>
<CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2">
<ImageIcon className="size-8 text-muted-foreground" />
<Search className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No image models found</p>
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
</div>
</CommandEmpty>
@ -675,6 +794,174 @@ export function ModelSelector({
</CommandList>
</Command>
</TabsContent>
{/* ─── Vision Tab ─── */}
<TabsContent value="vision" className="mt-0">
<Command
shouldFilter={false}
className="rounded-none rounded-b-lg dark:bg-neutral-900 [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
>
{totalVisionModels > 3 && (
<div className="px-2 md:px-3 py-1.5 md:py-2">
<CommandInput
placeholder="Search vision models"
value={visionSearchQuery}
onValueChange={setVisionSearchQuery}
className="h-7 md:h-8 w-full text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
/>
</div>
)}
<CommandList
className="max-h-[300px] md:max-h-[400px] overflow-y-auto"
onScroll={handleListScroll(setVisionScrollPos)}
style={{
maskImage: `linear-gradient(to bottom, ${visionScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${visionScrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${visionScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${visionScrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
<CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2">
<Search className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No vision models found</p>
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
</div>
</CommandEmpty>
{filteredVisionGlobal.length > 0 && (
<CommandGroup>
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
Global Vision Models
</div>
{filteredVisionGlobal.map((config) => {
const isSelected = currentVisionConfig?.id === config.id;
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
return (
<CommandItem
key={`vis-g-${config.id}`}
value={`vis-g-${config.id}`}
onSelect={() => handleSelectVision(config.id)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
isSelected && "bg-accent/80 dark:bg-white/[0.06]"
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="shrink-0">
{getProviderIcon(config.provider, { isAutoMode: isAuto })}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{config.name}</span>
{isAuto && (
<Badge
variant="secondary"
className="text-[9px] px-1 py-0 h-3.5 bg-violet-800 text-white dark:bg-violet-800 dark:text-white border-0"
>
Recommended
</Badge>
)}
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
</div>
<span className="text-xs text-muted-foreground truncate block">
{isAuto ? "Auto Mode" : config.model_name}
</span>
</div>
{onEditVision && !isAuto && (
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
setOpen(false);
onEditVision(config as VisionLLMConfig, true);
}}
>
<Edit3 className="size-3.5 text-muted-foreground" />
</Button>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
)}
{filteredVisionUser.length > 0 && (
<>
{filteredVisionGlobal.length > 0 && (
<CommandSeparator className="my-1 mx-4 bg-border/60" />
)}
<CommandGroup>
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
Your Vision Models
</div>
{filteredVisionUser.map((config) => {
const isSelected = currentVisionConfig?.id === config.id;
return (
<CommandItem
key={`vis-u-${config.id}`}
value={`vis-u-${config.id}`}
onSelect={() => handleSelectVision(config.id)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
isSelected && "bg-accent/80 dark:bg-white/[0.06]"
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="shrink-0">{getProviderIcon(config.provider)}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{config.name}</span>
{isSelected && (
<Check className="size-3.5 text-primary shrink-0" />
)}
</div>
<span className="text-xs text-muted-foreground truncate block">
{config.model_name}
</span>
</div>
{onEditVision && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
setOpen(false);
onEditVision(config, false);
}}
>
<Edit3 className="size-3.5 text-muted-foreground" />
</Button>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</>
)}
{onAddNewVision && (
<div className="p-2 bg-muted/20 dark:bg-neutral-900">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/[0.06]"
onClick={() => {
setOpen(false);
onAddNewVision();
}}
>
<Plus className="size-4 text-primary" />
<span className="text-sm font-medium">Add Vision Model</span>
</Button>
</div>
)}
</CommandList>
</Command>
</TabsContent>
</Tabs>
</PopoverContent>
</Popover>

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);
@ -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]);
@ -598,11 +602,11 @@ export function OnboardingTour() {
};
window.addEventListener("resize", handleUpdate);
window.addEventListener("scroll", handleUpdate, true);
window.addEventListener("scroll", handleUpdate, { capture: true, passive: true });
return () => {
window.removeEventListener("resize", handleUpdate);
window.removeEventListener("scroll", handleUpdate, true);
window.removeEventListener("scroll", handleUpdate, { capture: true });
};
}, [isActive, targetEl, currentStep?.placement]);

View file

@ -0,0 +1,16 @@
"use client";
import type { ReactNode } from "react";
import { usePlatform } from "@/hooks/use-platform";
export function DesktopOnly({ children }: { children: ReactNode }) {
const { isDesktop } = usePlatform();
if (!isDesktop) return null;
return <>{children}</>;
}
export function WebOnly({ children }: { children: ReactNode }) {
const { isWeb } = usePlatform();
if (!isWeb) return null;
return <>{children}</>;
}

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,16 +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,
@ -25,6 +24,10 @@ import {
llmPreferencesAtom,
newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import {
globalVisionLLMConfigsAtom,
visionLLMConfigsAtom,
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -40,6 +43,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";
@ -48,8 +52,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,
},
@ -57,8 +61,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,
},
@ -66,11 +70,20 @@ 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,
},
vision: {
icon: Eye,
title: "Vision LLM",
description: "Vision-capable model for screenshot analysis and context extraction",
color: "text-amber-600 dark:text-amber-400",
bgColor: "bg-amber-500/10",
prefKey: "vision_llm_config_id" as const,
configType: "vision" as const,
},
};
interface LLMRoleManagerProps {
@ -103,6 +116,18 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
error: globalImageConfigsError,
} = useAtomValue(globalImageGenConfigsAtom);
// Vision LLM configs
const {
data: userVisionConfigs = [],
isFetching: visionConfigsLoading,
error: visionConfigsError,
} = useAtomValue(visionLLMConfigsAtom);
const {
data: globalVisionConfigs = [],
isFetching: globalVisionConfigsLoading,
error: globalVisionConfigsError,
} = useAtomValue(globalVisionLLMConfigsAtom);
// Preferences
const {
data: preferences = {},
@ -116,90 +141,49 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
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_config_id: preferences.vision_llm_config_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 ?? "",
};
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_config_id: preferences.vision_llm_config_id ?? "",
});
}
}, [
preferences?.agent_llm_id,
preferences?.document_summary_llm_id,
preferences?.image_generation_config_id,
preferences?.vision_llm_config_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 ?? "",
};
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),
};
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 ?? "",
});
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;
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 = [
@ -213,18 +197,35 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
...(userImageConfigs ?? []).filter((config) => config.id && config.id.toString().trim() !== ""),
];
// Combine global and custom vision LLM configs
const allVisionConfigs = [
...globalVisionConfigs.map((config) => ({ ...config, is_global: true })),
...(userVisionConfigs ?? []).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 ||
globalConfigsLoading ||
imageConfigsLoading ||
globalImageConfigsLoading;
globalImageConfigsLoading ||
visionConfigsLoading ||
globalVisionConfigsLoading;
const hasError =
configsError ||
preferencesError ||
globalConfigsError ||
imageConfigsError ||
globalImageConfigsError;
globalImageConfigsError ||
visionConfigsError ||
globalVisionConfigsError;
const hasAnyConfigs = allLLMConfigs.length > 0 || allImageConfigs.length > 0;
return (
@ -242,11 +243,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>
)}
@ -321,21 +319,30 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
const IconComponent = role.icon;
const isImageRole = role.configType === "image";
const currentAssignment = assignments[role.prefKey as keyof typeof assignments];
// Pick the right config lists based on role type
const roleGlobalConfigs = isImageRole ? globalImageConfigs : globalConfigs;
const roleUserConfigs = isImageRole
? (userImageConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
: newLLMConfigs.filter((c) => c.id && c.id.toString().trim() !== "");
const roleAllConfigs = isImageRole ? allImageConfigs : allLLMConfigs;
const roleGlobalConfigs =
role.configType === "image"
? globalImageConfigs
: role.configType === "vision"
? globalVisionConfigs
: globalConfigs;
const roleUserConfigs =
role.configType === "image"
? (userImageConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
: role.configType === "vision"
? (userVisionConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
: newLLMConfigs.filter((c) => c.id && c.id.toString().trim() !== "");
const roleAllConfigs =
role.configType === "image"
? allImageConfigs
: role.configType === "vision"
? allVisionConfigs
: 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;
@ -361,8 +368,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" />
)}
@ -374,7 +383,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">
@ -404,13 +413,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",
@ -533,34 +536,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,7 +1,7 @@
"use client";
import { useAtom } from "jotai";
import { Bot, Brain, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
import { Bot, Brain, Eye, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
import { useTranslations } from "next-intl";
import type React from "react";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
@ -13,6 +13,7 @@ import { ModelConfigManager } from "@/components/settings/model-config-manager";
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
import { RolesManager } from "@/components/settings/roles-manager";
import { SettingsDialog } from "@/components/settings/settings-dialog";
import { VisionModelManager } from "@/components/settings/vision-model-manager";
interface SearchSpaceSettingsDialogProps {
searchSpaceId: number;
@ -31,6 +32,11 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
label: t("nav_image_models"),
icon: <ImageIcon className="h-4 w-4" />,
},
{
value: "vision-models",
label: t("nav_vision_models"),
icon: <Eye className="h-4 w-4" />,
},
{ value: "team-roles", label: t("nav_team_roles"), icon: <Shield className="h-4 w-4" /> },
{
value: "prompts",
@ -45,6 +51,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
models: <ModelConfigManager searchSpaceId={searchSpaceId} />,
roles: <LLMRoleManager searchSpaceId={searchSpaceId} />,
"image-models": <ImageModelManager searchSpaceId={searchSpaceId} />,
"vision-models": <VisionModelManager searchSpaceId={searchSpaceId} />,
"team-roles": <RolesManager searchSpaceId={searchSpaceId} />,
prompts: <PromptConfigManager searchSpaceId={searchSpaceId} />,
"public-links": <PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />,

View file

@ -1,43 +1,53 @@
"use client";
import { useAtom } from "jotai";
import { Globe, KeyRound, Receipt, Sparkles, User } from "lucide-react";
import { Globe, KeyRound, Monitor, Receipt, Sparkles, User } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
import { CommunityPromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent";
import { DesktopContent } from "@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent";
import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent";
import { PromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PromptsContent";
import { PurchaseHistoryContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { SettingsDialog } from "@/components/settings/settings-dialog";
import { usePlatform } from "@/hooks/use-platform";
export function UserSettingsDialog() {
const t = useTranslations("userSettings");
const [state, setState] = useAtom(userSettingsDialogAtom);
const { isDesktop } = usePlatform();
const navItems = [
{ value: "profile", label: t("profile_nav_label"), icon: <User className="h-4 w-4" /> },
{
value: "api-key",
label: t("api_key_nav_label"),
icon: <KeyRound className="h-4 w-4" />,
},
{
value: "prompts",
label: "My Prompts",
icon: <Sparkles className="h-4 w-4" />,
},
{
value: "community-prompts",
label: "Community Prompts",
icon: <Globe className="h-4 w-4" />,
},
{
value: "purchases",
label: "Purchase History",
icon: <Receipt className="h-4 w-4" />,
},
];
const navItems = useMemo(
() => [
{ value: "profile", label: t("profile_nav_label"), icon: <User className="h-4 w-4" /> },
{
value: "api-key",
label: t("api_key_nav_label"),
icon: <KeyRound className="h-4 w-4" />,
},
{
value: "prompts",
label: "My Prompts",
icon: <Sparkles className="h-4 w-4" />,
},
{
value: "community-prompts",
label: "Community Prompts",
icon: <Globe className="h-4 w-4" />,
},
{
value: "purchases",
label: "Purchase History",
icon: <Receipt className="h-4 w-4" />,
},
...(isDesktop
? [{ value: "desktop", label: "Desktop", icon: <Monitor className="h-4 w-4" /> }]
: []),
],
[t, isDesktop]
);
return (
<SettingsDialog
@ -54,6 +64,7 @@ export function UserSettingsDialog() {
{state.initialTab === "prompts" && <PromptsContent />}
{state.initialTab === "community-prompts" && <CommunityPromptsContent />}
{state.initialTab === "purchases" && <PurchaseHistoryContent />}
{state.initialTab === "desktop" && <DesktopContent />}
</div>
</SettingsDialog>
);

View file

@ -0,0 +1,401 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { deleteVisionLLMConfigMutationAtom } from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
import {
globalVisionLLMConfigsAtom,
visionLLMConfigsAtom,
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
import { VisionConfigDialog } from "@/components/shared/vision-config-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { VisionLLMConfig } from "@/contracts/types/new-llm-config.types";
import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
interface VisionModelManagerProps {
searchSpaceId: number;
}
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
const isDesktop = useMediaQuery("(min-width: 768px)");
const {
mutateAsync: deleteConfig,
isPending: isDeleting,
error: deleteError,
} = useAtomValue(deleteVisionLLMConfigMutationAtom);
const {
data: userConfigs,
isFetching: configsLoading,
error: fetchError,
refetch: refreshConfigs,
} = useAtomValue(visionLLMConfigsAtom);
const { data: globalConfigs = [], isFetching: globalLoading } = useAtomValue(
globalVisionLLMConfigsAtom
);
const { data: members } = useAtomValue(membersAtom);
const memberMap = useMemo(() => {
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
if (members) {
for (const m of members) {
map.set(m.user_id, {
name: m.user_display_name || m.user_email || "Unknown",
email: m.user_email || undefined,
avatarUrl: m.user_avatar_url || undefined,
});
}
}
return map;
}, [members]);
const { data: access } = useAtomValue(myAccessAtom);
const canCreate = useMemo(() => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes("vision_configs:create") ?? false;
}, [access]);
const canDelete = useMemo(() => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes("vision_configs:delete") ?? false;
}, [access]);
const canUpdate = canCreate;
const isReadOnly = !canCreate && !canDelete;
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingConfig, setEditingConfig] = useState<VisionLLMConfig | null>(null);
const [configToDelete, setConfigToDelete] = useState<VisionLLMConfig | null>(null);
const isLoading = configsLoading || globalLoading;
const errors = [deleteError, fetchError].filter(Boolean) as Error[];
const openEditDialog = (config: VisionLLMConfig) => {
setEditingConfig(config);
setIsDialogOpen(true);
};
const openNewDialog = () => {
setEditingConfig(null);
setIsDialogOpen(true);
};
const handleDelete = async () => {
if (!configToDelete) return;
try {
await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
setConfigToDelete(null);
} catch {
// Error handled by mutation
}
};
return (
<div className="space-y-4 md:space-y-6">
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<Button
variant="secondary"
size="sm"
onClick={() => refreshConfigs()}
disabled={isLoading}
className="gap-2"
>
<RefreshCw className={cn("h-3.5 w-3.5", configsLoading && "animate-spin")} />
Refresh
</Button>
{canCreate && (
<Button
variant="outline"
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 Vision Model
</Button>
)}
</div>
{errors.map((err) => (
<div key={err?.message}>
<Alert variant="destructive" className="py-3">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
</Alert>
</div>
))}
{access && !isLoading && isReadOnly && (
<div>
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
You have <span className="font-medium">read-only</span> access to vision model
configurations. Contact a space owner to request additional permissions.
</AlertDescription>
</Alert>
</div>
)}
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
<div>
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
You can{" "}
{[canCreate && "create and edit", canDelete && "delete"]
.filter(Boolean)
.join(" and ")}{" "}
vision model configurations
{!canDelete && ", but cannot delete them"}.
</AlertDescription>
</Alert>
</div>
)}
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
<Alert className="bg-muted/50 py-3">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
<p>
<span className="font-medium">
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
global vision{" "}
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length === 1
? "model"
: "models"}
</span>{" "}
available from your administrator. Use the model selector to view and select them.
</p>
</AlertDescription>
</Alert>
)}
{isLoading && (
<div className="space-y-4 md:space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-6 md:h-7 w-40 md:w-48" />
<Skeleton className="h-8 md:h-9 w-32 md:w-36 rounded-md" />
</div>
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-border/60">
<CardContent className="p-4 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1.5 flex-1 min-w-0">
<Skeleton className="h-4 w-28 md:w-32" />
<Skeleton className="h-3 w-40 md:w-48" />
</div>
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-24 rounded-md" />
</div>
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-3 w-16" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
</div>
)}
{!isLoading && (
<div className="space-y-4 md:space-y-6">
{(userConfigs?.length ?? 0) === 0 ? (
<Card className="border-0 bg-transparent shadow-none">
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
<h3 className="text-sm md:text-base font-semibold mb-2">No Vision Models Yet</h3>
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
{canCreate
? "Add your own vision-capable model (GPT-4o, Claude, Gemini, etc.)"
: "No vision models have been added to this space yet. Contact a space owner to add one."}
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{userConfigs?.map((config) => {
const member = config.user_id ? memberMap.get(config.user_id) : null;
return (
<div key={config.id}>
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold tracking-tight truncate">
{config.name}
</h4>
{config.description && (
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
{config.description}
</p>
)}
</div>
{(canUpdate || canDelete) && (
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
{canUpdate && (
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(config)}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
>
<Edit3 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{canDelete && (
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setConfigToDelete(config)}
className="h-7 w-7 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{getProviderIcon(config.provider, {
className: "size-3.5 shrink-0",
})}
<code className="text-[11px] font-mono text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-md truncate max-w-[160px]">
{config.model_name}
</code>
</div>
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto">
<span className="text-[11px] text-muted-foreground/60">
{new Date(config.created_at).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
{member && (
<>
<Dot className="h-4 w-4 text-muted-foreground/30" />
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default">
<Avatar className="size-4.5 shrink-0">
{member.avatarUrl && (
<AvatarImage src={member.avatarUrl} alt={member.name} />
)}
<AvatarFallback className="text-[9px]">
{getInitials(member.name)}
</AvatarFallback>
</Avatar>
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
{member.name}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{member.email || member.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</div>
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</div>
)}
<VisionConfigDialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) setEditingConfig(null);
}}
config={editingConfig}
isGlobal={false}
searchSpaceId={searchSpaceId}
mode={editingConfig ? "edit" : "create"}
/>
<AlertDialog
open={!!configToDelete}
onOpenChange={(open) => !open && setConfigToDelete(null)}
>
<AlertDialogContent className="select-none">
<AlertDialogHeader>
<AlertDialogTitle>Delete Vision Model</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{configToDelete?.name}</span>?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<span className={isDeleting ? "opacity-0" : ""}>Delete</span>
{isDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -433,7 +433,7 @@ export function ImageConfigDialog({
className="relative text-sm h-9 min-w-[120px]"
>
<span className={isSubmitting ? "opacity-0" : ""}>
{mode === "edit" ? "Save Changes" : "Create & Use"}
{mode === "edit" ? "Save Changes" : "Add Model"}
</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>

View file

@ -312,7 +312,7 @@ export function ModelConfigDialog({
className="relative text-sm h-9 min-w-[120px]"
>
<span className={isSubmitting ? "opacity-0" : ""}>
{mode === "edit" ? "Save Changes" : "Create & Use"}
{mode === "edit" ? "Save Changes" : "Add Model"}
</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>

View file

@ -0,0 +1,481 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import {
createVisionLLMConfigMutationAtom,
updateVisionLLMConfigMutationAtom,
} from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
import { visionModelListAtom } from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { VISION_PROVIDERS } from "@/contracts/enums/vision-providers";
import type {
GlobalVisionLLMConfig,
VisionLLMConfig,
VisionProvider,
} from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
interface VisionConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: VisionLLMConfig | GlobalVisionLLMConfig | null;
isGlobal: boolean;
searchSpaceId: number;
mode: "create" | "edit" | "view";
}
const INITIAL_FORM = {
name: "",
description: "",
provider: "",
model_name: "",
api_key: "",
api_base: "",
api_version: "",
};
export function VisionConfigDialog({
open,
onOpenChange,
config,
isGlobal,
searchSpaceId,
mode,
}: VisionConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState(INITIAL_FORM);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) {
if (mode === "edit" && config && !isGlobal) {
setFormData({
name: config.name || "",
description: config.description || "",
provider: config.provider || "",
model_name: config.model_name || "",
api_key: (config as VisionLLMConfig).api_key || "",
api_base: config.api_base || "",
api_version: (config as VisionLLMConfig).api_version || "",
});
} else if (mode === "create") {
setFormData(INITIAL_FORM);
}
setScrollPos("top");
}
}, [open, mode, config, isGlobal]);
const { mutateAsync: createConfig } = useAtomValue(createVisionLLMConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateVisionLLMConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const getTitle = () => {
if (mode === "create") return "Add Vision Model";
if (isGlobal) return "View Global Vision Model";
return "Edit Vision Model";
};
const getSubtitle = () => {
if (mode === "create") return "Set up a new vision-capable LLM provider";
if (isGlobal) return "Read-only global configuration";
return "Update your vision model settings";
};
const handleSubmit = useCallback(async () => {
setIsSubmitting(true);
try {
if (mode === "create") {
const result = await createConfig({
name: formData.name,
provider: formData.provider as VisionProvider,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
description: formData.description || undefined,
search_space_id: searchSpaceId,
});
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: { vision_llm_config_id: result.id },
});
}
onOpenChange(false);
} else if (!isGlobal && config) {
await updateConfig({
id: config.id,
data: {
name: formData.name,
description: formData.description || undefined,
provider: formData.provider as VisionProvider,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
},
});
onOpenChange(false);
}
} catch (error) {
console.error("Failed to save vision config:", error);
toast.error("Failed to save vision model");
} finally {
setIsSubmitting(false);
}
}, [
mode,
isGlobal,
config,
formData,
searchSpaceId,
createConfig,
updateConfig,
updatePreferences,
onOpenChange,
]);
const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return;
setIsSubmitting(true);
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: { vision_llm_config_id: config.id },
});
toast.success(`Now using ${config.name}`);
onOpenChange(false);
} catch (error) {
console.error("Failed to set vision model:", error);
toast.error("Failed to set vision model");
} finally {
setIsSubmitting(false);
}
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
const { data: dynamicModels } = useAtomValue(visionModelListAtom);
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
const availableModels = useMemo(
() => (dynamicModels ?? []).filter((m) => m.provider === formData.provider),
[dynamicModels, formData.provider]
);
const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key;
const selectedProvider = VISION_PROVIDERS.find((p) => p.value === formData.provider);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
{isGlobal && mode !== "create" && (
<Badge variant="secondary" className="text-[10px]">
Global
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
)}
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{isGlobal && config && (
<>
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
<AlertCircle className="size-4 text-amber-500" />
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
Global configurations are read-only. To customize, create a new model.
</AlertDescription>
</Alert>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Name
</div>
<p className="text-sm font-medium">{config.name}</p>
</div>
{config.description && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Description
</div>
<p className="text-sm text-muted-foreground">{config.description}</p>
</div>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Provider
</div>
<p className="text-sm font-medium">{config.provider}</p>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Model
</div>
<p className="text-sm font-medium font-mono">{config.model_name}</p>
</div>
</div>
</div>
</>
)}
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Name *</Label>
<Input
placeholder="e.g., My GPT-4o Vision"
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Description</Label>
<Input
placeholder="Optional description"
value={formData.description}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
/>
</div>
<Separator />
<div className="space-y-2">
<Label className="text-sm font-medium">Provider *</Label>
<Select
value={formData.provider}
onValueChange={(val) =>
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{VISION_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value} description={p.example}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Model Name *</Label>
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modelComboboxOpen}
className={cn(
"w-full justify-between font-normal bg-transparent",
!formData.model_name && "text-muted-foreground"
)}
>
{formData.model_name || "Select a model"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0 bg-muted dark:border-neutral-700"
align="start"
>
<Command shouldFilter={false} className="bg-transparent">
<CommandInput
placeholder={selectedProvider?.example || "Search model name"}
value={formData.model_name}
onValueChange={(val) =>
setFormData((p) => ({ ...p, model_name: val }))
}
/>
<CommandList className="max-h-[300px]">
<CommandEmpty>
<div className="py-3 text-center text-sm text-muted-foreground">
{formData.model_name
? `Using: "${formData.model_name}"`
: "Type your model name"}
</div>
</CommandEmpty>
{availableModels.length > 0 && (
<CommandGroup heading="Suggested Models">
{availableModels
.filter(
(model) =>
!formData.model_name ||
model.value
.toLowerCase()
.includes(formData.model_name.toLowerCase()) ||
model.label
.toLowerCase()
.includes(formData.model_name.toLowerCase())
)
.slice(0, 50)
.map((model) => (
<CommandItem
key={model.value}
value={model.value}
onSelect={(value) => {
setFormData((p) => ({
...p,
model_name: value,
}));
setModelComboboxOpen(false);
}}
className="py-2"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.model_name === model.value
? "opacity-100"
: "opacity-0"
)}
/>
<div>
<div className="font-medium">{model.label}</div>
{model.contextWindow && (
<div className="text-xs text-muted-foreground">
Context: {model.contextWindow}
</div>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">API Key *</Label>
<Input
type="password"
placeholder="sk-..."
value={formData.api_key}
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">API Base URL</Label>
<Input
placeholder={selectedProvider?.apiBase || "Optional"}
value={formData.api_base}
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
/>
</div>
{formData.provider === "AZURE_OPENAI" && (
<div className="space-y-2">
<Label className="text-sm font-medium">API Version (Azure)</Label>
<Input
placeholder="2024-02-15-preview"
value={formData.api_version}
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
/>
</div>
)}
</div>
)}
</div>
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
Cancel
</Button>
{mode === "create" || (mode === "edit" && !isGlobal) ? (
<Button
onClick={handleSubmit}
disabled={isSubmitting || !isFormValid}
className="relative text-sm h-9 min-w-[120px]"
>
<span className={isSubmitting ? "opacity-0" : ""}>
{mode === "edit" ? "Save Changes" : "Add Model"}
</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : isGlobal && config ? (
<Button
className="relative text-sm h-9"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : null}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -1,33 +1,42 @@
"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 { useElectronAPI } from "@/hooks/use-platform";
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 +60,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,8 +86,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"],
...audioFileTypes,
@ -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,22 @@ 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 electronAPI = useElectronAPI();
const [selectedFolder, setSelectedFolder] = useState<SelectedFolder | null>(null);
const [watchFolder, setWatchFolder] = useState(true);
const [folderSubmitting, setFolderSubmitting] = useState(false);
const isElectron = !!electronAPI?.browseFiles;
const acceptedFileTypes = useMemo(() => {
const etlService = process.env.NEXT_PUBLIC_ETL_SERVICE;
@ -145,49 +167,104 @@ 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 () => {
if (!electronAPI?.browseFiles) return;
const paths = await electronAPI.browseFiles();
if (!paths || paths.length === 0) return;
setSelectedFolder(null);
const fileDataList = await electronAPI.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]);
}, [electronAPI]);
const handleBrowseFolder = useCallback(async () => {
if (!electronAPI?.selectFolder) return;
const folderPath = await electronAPI.selectFolder();
if (!folderPath) return;
const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath;
setFiles([]);
setSelectedFolder({ path: folderPath, name: folderName });
setWatchFolder(true);
}, [electronAPI]);
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 +275,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 +285,57 @@ export function DocumentUploadTab({
[onAccordionStateChange]
);
const handleFolderSubmit = useCallback(async () => {
if (!selectedFolder || !electronAPI) 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 electronAPI.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, electronAPI]);
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 +348,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 +367,333 @@ 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>
))
) : (
<button
type="button"
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent border-none"
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>
{/* biome-ignore lint/a11y/useSemanticElements: wrapper to stop click propagation to parent button */}
<div
className="w-full mt-1"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="group"
>
{renderBrowseButton({ fullWidth: true })}
</div>
</button>
)}
</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>
</CardContent>
</Card>
{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>
) : (
<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.5">
{supportedExtensions.map((ext) => (
<Badge key={ext} variant="outline" className="text-xs">
<Badge
key={ext}
variant="secondary"
className="rounded border-0 bg-neutral-200/80 dark:bg-neutral-700/60 text-muted-foreground text-[10px] px-2 py-0.5 font-normal"
>
{ext}
</Badge>
))}

View file

@ -2,6 +2,7 @@
import type { LucideIcon } from "lucide-react";
import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
import NextImage from "next/image";
import * as React from "react";
import { openSafeNavigationHref, resolveSafeNavigationHref } from "../shared/media";
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
@ -253,17 +254,17 @@ 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">
@ -339,14 +340,14 @@ 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
<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" />

View file

@ -2,6 +2,7 @@
import type { LucideIcon } from "lucide-react";
import { Code2, Database, ExternalLink, File, FileText, Globe, Newspaper } from "lucide-react";
import NextImage from "next/image";
import * as React from "react";
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
@ -114,14 +115,14 @@ export function Citation(props: CitationProps) {
};
const iconElement = favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain — next/image requires remotePatterns config
<img
<NextImage
src={favicon}
alt=""
aria-hidden="true"
width={14}
height={14}
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" />

View file

@ -307,6 +307,7 @@ export function Image({
src={src}
alt={alt}
fill
sizes="(max-width: 512px) 100vw, 512px"
className={cn(
"transition-transform duration-300",
fit === "cover" ? "object-cover" : "object-contain",

View file

@ -7,6 +7,7 @@ import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
@ -201,9 +202,12 @@ const Tabs = forwardRef<
},
[onValueChange, value]
);
const contextValue = useMemo(
() => ({ activeValue, onValueChange: handleValueChange }),
[activeValue, handleValueChange]
);
return (
<TabsContext.Provider value={{ activeValue, onValueChange: handleValueChange }}>
<TabsContext.Provider value={contextValue}>
<div ref={ref} className={cn("tabs-container", className)} {...props}>
{children}
</div>

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

@ -65,7 +65,7 @@ export function FloatingToolbar({
{...rootProps}
ref={ref}
className={cn(
"scrollbar-hide absolute z-50 overflow-x-auto whitespace-nowrap rounded-md border bg-popover p-1 opacity-100 shadow-md print:hidden dark:bg-neutral-900 dark:border-white/5",
"scrollbar-hide absolute z-50 overflow-x-auto whitespace-nowrap rounded-md border bg-popover p-1 opacity-100 shadow-md print:hidden",
"max-w-[80vw]",
className
)}

View file

@ -189,7 +189,7 @@ export function InsertToolbarButton(props: DropdownMenuProps) {
</DropdownMenuTrigger>
<DropdownMenuContent
className="z-[100] flex max-h-[60vh] min-w-0 flex-col overflow-y-auto dark:bg-neutral-900 dark:border dark:border-white/5"
className="z-[100] flex max-h-[60vh] min-w-0 flex-col overflow-y-auto"
align="start"
>
{groups.map(({ group, items }) => (

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

@ -176,7 +176,7 @@ export function SlashInputElement({ children, ...props }: PlateElementProps) {
<InlineCombobox element={props.element} trigger="/">
<InlineComboboxInput />
<InlineComboboxContent className="dark:bg-neutral-900 dark:border dark:border-white/5">
<InlineComboboxContent>
<InlineComboboxEmpty>No results found.</InlineComboboxEmpty>
{slashCommandGroups.map(({ heading, items }) => (

View file

@ -1,8 +1,9 @@
"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 { useMemo } from "react";
import { toggleVariants } from "@/components/ui/toggle";
import { cn } from "@/lib/utils";
@ -27,6 +28,8 @@ function ToggleGroup({
VariantProps<typeof toggleVariants> & {
spacing?: number;
}) {
const contextValue = useMemo(() => ({ variant, size, spacing }), [variant, size, spacing]);
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
@ -40,9 +43,7 @@ function ToggleGroup({
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
<ToggleGroupContext.Provider value={contextValue}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}

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,10 +1,27 @@
"use client";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type * as React from "react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
const MOBILE_BREAKPOINT = 768;
function useIsTouchDevice() {
const [isTouch, setIsTouch] = useState(false);
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const update = () => setIsTouch(mql.matches);
update();
mql.addEventListener("change", update);
return () => mql.removeEventListener("change", update);
}, []);
return isTouch;
}
function TooltipProvider({
delayDuration = 0,
disableHoverableContent = true,
@ -20,10 +37,21 @@ function TooltipProvider({
);
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
function Tooltip({
open,
onOpenChange,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
const isMobile = useIsTouchDevice();
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
<TooltipPrimitive.Root
data-slot="tooltip"
open={isMobile ? false : open}
onOpenChange={isMobile ? undefined : onOpenChange}
{...props}
/>
</TooltipProvider>
);
}

View file

@ -150,7 +150,7 @@ export function TurnIntoToolbarButton({
</DropdownMenuTrigger>
<DropdownMenuContent
className="z-[100] ignore-click-outside/toolbar min-w-0 max-h-[60vh] overflow-y-auto dark:bg-neutral-900 dark:border dark:border-white/5"
className="z-[100] ignore-click-outside/toolbar min-w-0 max-h-[60vh] overflow-y-auto"
onCloseAutoFocus={(e) => {
e.preventDefault();
editor.tf.focus();